StarkNet Smart Contract Common Pitfalls

Inspex
7 min readAug 8, 2022

--

There are currently a lot of solutions that have been introduced to solve the Ethereum scaling and gas pricing issues. Rollups is a layer 2 blockchain solution that is used to improve the scalability by operating the computational part on layer 2, while storing the data on layer 1. In this article, we will focus on a zk-rollups called StarkNet in terms of the common issues that every StarkNet developer should be concern about.

StarkNet Contract Common Pitfalls

What is StarkNet

StarkNet is a permissionless decentralized ZK-Rollup operating as an L2 network over Ethereum, where any dApp can achieve unlimited scale for its computation, without compromising Ethereum’s composability and security. — STARK.

Cairo Basic

Before going through the common pitfalls, let’s start with the Cairo basic syntax. StarkNet uses the Cairo programming language for writing StarkNet contracts. We have wrapped up some basics that you might need to understand first.

Field Element (felt)

There is only one data type in Cairo called field element (felt). The felt is a signed integer in the range from -P/2 < x < P/2 where P is a very large (prime) number (252-bit).

Variable Declaration

In Cairo, a variable can be declared by using the let, const, local or tempvar keywords.

  • let keyword is used to create a variable that refers to the other variable or statement (can be re-assigned). These reference variables are commonly used to store the return values of function calls.
let a = 1
let (bal) = balance.read()
  • const keyword is used to create a constant variable that cannot be re-assigned.
const ONE = 1
const hello_string = 'hello' # 448378203247
  • local keyword is used to create a local variable that cannot be re-assigned. Adding the alloc_locals to the beginning of the function is required.
alloc_locals
local a = b
  • tempvar keyword is used to assigned a value to the variable that can be re-assigned.
tempvar a = 1

Storage Variable

The @storage_var decorator declares a variable that will be kept in the storage. In this case, this variable consists of a single felt, called balance.

# Define a storage variable.
@storage_var
func balance() -> (res : felt):
end

To use this variable, we will use the balance.read() and balance.write() functions.

# Read a storage variable.
let (bal) = balance.read()
# Write a storage variable.
balance.write(initial_balance)

Storage Maps

Adding the argument to the storage variable will change the balance storage variable to a map from the public key (user) to balance (act like Solidity keyword mapping)

# A map from user (represented by account contract address)
# to their balance.
@storage_var
func balance(user : felt) -> (res : felt):
end

To use this mapping variable, we will use the balance.read() and balance.write() functions with an user value as an additional argument.

# Read a storage variable.
let (bal) = balance.read(user)
# Write a storage variable.
balance.write(user, initial_balance)

Function Visibility

Normally, all functions in StarkNet are internal functions that can only be called by the contract itself. Using @view or @external decorators will expose the function to be called by other contracts.

The @view decorator is used to define the function that only queries and does not modify the storage variable.

# Returns the current balance.
@view
func get_balance{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr,
}() -> (res : felt):
let (bal) = balance.read()
return (res=bal)
end

The function with the @external decorator can be called by the users of StarkNet, by other contracts, and by the contract itself (acts like public visibility in the Solidity).

# Increases the balance by the given amount.
@external
func increase_balance{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr,
}(amount : felt):
let (bal) = balance.read()
balance.write(bal + amount)
# Emit the event.
update_balance.emit(new_balance=bal + amount)
return ()
end

Getting the Caller’s Address

In order to obtain the address of the account contract that is invoking our function, we can use the get_caller_address() library function:

from starkware.starknet.common.syscalls import get_caller_address# ...
let (caller_address) = get_caller_address()

The get_caller_address() function returns an address that can be the address of the account contract or another contract (if cross-contract call). When the contract is called directly (rather than through a contract), the function returns 0.

Cross-Contract Call

Define an interface by using the @contract_interface decorator with the namespace keyword and declare the external functions in order to call this contract from another contract:

@contract_interface
namespace ICounterContract:
func increase_balance(amount : felt):
end
func get_balance() -> (res : felt):
end
end

Then use ICounterContract.increase_balance() and ICounterContract.get_balance() to invoke these functions from another contract by passing the destination contract address as the first argument, contract_address.

@external
func call_increase_balance{syscall_ptr : felt*, range_check_ptr}(
contract_address : felt, amount : felt
):
ICounterContract.increase_balance(
contract_address=contract_address, amount=amount
)
return ()
end
@view
func call_get_balance{syscall_ptr : felt*, range_check_ptr}(
contract_address : felt
) -> (res : felt):
let (res) = ICounterContract.get_balance(
contract_address=contract_address
)
return (res=res)
end

Common Pitfalls in StarkNet

Let’s move to the main topic, the following examples show the common issues that might be encountered in the StarkNet contract development.

Arithmetic Issue

Input Validation

Since, the felt variable has range from -P/2 < x < P/2 where P is a very large (prime) number (252-bit), the contract must be implemented carefully as the variable can store a negative value.

For example, the following transfer() function takes the amount as an input parameter.

@external
func transfer{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(from_addr : felt, to_addr : felt, amount : felt):
alloc_locals
let (from_bal) = balance.read(from_addr)
let (to_bal) = balance.read(to_addr)
balance.write(from_addr, from_bal - amount)
balance.write(to_addr, to_bal + amount)
return ()
end

The malicious user can supply a negative number as an input, resulting in draining the balance from the destination to the malicious user instead.

Integer Overflow

Performing mathematical operation in Cairo might cause an unexpected result. The above-mentioned transfer function is also vulnerable to the integer overflow issue. When the to_bal + amount is larger than the maximum integer value (P/2), the result will overflow and wrapped around to a very large negative value.

balance.write(to_addr, to_bal + amount)

Solution: Check whether the user-supplied input is in the range, e.g., by using library functions such as is_in_range(), is_nn() or uint256_check(), and always use the safe math library to perform arithmetic operation, e.g., uint256_sub(), uint256_add() or unsigned_div_rem().

@external
func transfer{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(from_addr : felt, to_addr : felt, amount : felt):
alloc_locals
# check the amount is non negative value
assert_nn(amount)
let (from_bal) = balance.read(from_addr)
let (to_bal) = balance.read(to_addr)
# check overflow/underflow
assert_nn(from_bal - amount)
assert_nn(to_bal + amount)
balance.write(from_addr, from_bal - amount)
balance.write(to_addr, to_bal + amount)
return ()
end

Authentication & Authorization

The user authorization in StarkNet can be checked by using the get_caller_address() system call function which returns the caller of the function call.

The following example shows a broken access control issue in the StarkNet, the transfer() function gets the from_addr as an input parameter without verifying the caller’s authorization.

@external
func transfer{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(from_addr : felt, to_addr : felt, amount : felt):
alloc_locals
# check the amount is non negative value
assert_nn(amount)
let (from_bal) = balance.read(from_addr)
let (to_bal) = balance.read(to_addr)
# check overflow/underflow
assert_nn(from_bal - amount)
assert_nn(to_bal + amount)
balance.write(from_addr, from_bal - amount)
balance.write(to_addr, to_bal + amount)
return ()
end

Solution: Implement an authentication scheme based on the business design by using the get_caller_address() function to prevent a malicious actor from bypassing the authentication or acting as another user.

@external
func transfer{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(to_addr : felt, amount : felt):
alloc_locals
# check the amount is non negative value
assert_nn(amount)
# get caller address
let (from_addr) = get_caller_address()
let (from_bal) = balance.read(from_addr)
let (to_bal) = balance.read(to_addr)
# check overflow/underflow
assert_nn(from_bal - amount)
assert_nn(to_bal + amount)
balance.write(from_addr, from_bal - amount)
balance.write(to_addr, to_bal + amount)
return ()
end

Reentrancy Attack

Even if there is no native token callback in StarkNet, poor implementation or callbacks in various standards (ERC721.onerc721received, ERC777.tokensReceived, etc.) may still be vulnerable to reentrancy attacks.

The following withdrawAll() function is vulnerable to the reentrancy attack. An attacker can hijack the control flow by implementing the ERC777.tokensReceived callback function that allows an attacker to reenter the withdrawAll() function and drain all tokens from the contract.

@external
func withdrawAll{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}():
alloc_locals
let (caller) = get_caller_address()
let (bal) = balance.read(caller)
assert_not_zero(bal)
let (token) = token.read()
IERC777.transfer(token, caller, bal)

balance.write(caller, 0)
return ()
end

Solution: Implement the Check-Effect-Interaction pattern or use the mutex lock mechanism to prevent the reentrancy attack.

@external
func withdrawAll{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}():
alloc_locals
# check
let (caller) = get_caller_address()
let (bal) = balance.read(caller)
assert_not_zero(bal)

# effect
balance.write(caller, 0)
# interact
let (token) = token.read()
IERC777.transfer(token, caller, bal)
return ()
end

Cross Chain Attack

In StarkNet the cross-chain message is free to be created by any address both for L1 to L2 and L2 to L1. The source of the cross-chain message must be checked to prevent a message spoofing attack.

The following example deposit() function does not verify the from_address properly and allows the attacker to send the spoofed deposit message from any L1 contract to make the deposit on the L2.

@l1_handler
func deposit{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(
from_address : felt,
l2_recipient : felt,
amount : felt,
sender_address : felt,
):
alloc_locals
let (token) = _token.read()
IToken.mint(token, l2_recipient, amount)
deposit.emit(l2_recipient, amount)
return ()
end

Solution: Always check the source of the cross-chain message that must be created by the trusted address.

@l1_handler
func deposit{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(
from_address : felt,
l2_recipient : felt,
amount : felt,
sender_address : felt,
):
alloc_locals
# check the from_address
let (l1_bridge) = _l1_bridge.read()
assert from_address = l1_bridge
let (token) = _token.read()
IToken.mint(token, l2_recipient, amount)
deposit.emit(l2_recipient, amount)
return ()
end

Conclusion

These typical StarkNet pitfalls are merely examples. Other weaknesses, like the business logic problems, may be hidden in the contract. New technology also brings new attack vectors, which both the platform developers and users must be aware of. We hope that these example vulnerabilities can help increase the awareness and improve the security level of the decentralized world.

These typical StarkNet pitfalls are merely examples. Other weaknesses, like the business logic problems, may be hidden in the contract. New technology also brings new attack vectors, which both the platform developers and users must be aware of. We hope that these example vulnerabilities can help increase the awareness and improve the security level of the decentralized world.

--

--

Inspex
Inspex

Written by Inspex

Cybersecurity professional service, specialized in blockchain and smart contract auditing https://twitter.com/InspexCo

No responses yet