Skip to main content
William Bowling

Exploring Cairo: A Security Primer

A brief look at Cairo 1.0, Starknet, and the security considerations
Article heading

Introduction

In this article, we will be looking at the recently released Cairo 1.0, Starknet’s native language. We will give a brief introduction to Cairo and Starknet, explore some security features of Cairo, and look at some potential pitfalls when writing contracts in Cairo. For anyone considering writing contracts in Cairo, this article will give you a starting point and some things to consider when writing secure code.

Meet Cairo 1.0

Cairo 1.0 is a Rust-inspired language designed to allow anyone to create STARK-provable smart contracts. It is the native language of Starknet, which is a zkRollup designed for high throughput and low gas costs. For the purposes of this article, we will be focusing on the security features of Cairo when used to write smart contracts on Starknet.

First, let’s begin by walking through a simple contract written in Cairo 1.0:

#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::ContractAddress;

struct Storage {
last_caller: ContractAddress,
}

#[event]
fn Hello(from: ContractAddress, value: felt252) {}


#[external]
fn say_hello(message: felt252) {
let caller = get_caller_address();
last_caller::write(caller);
Hello(caller, message);
}

#[view]
fn get_last_caller() -> ContractAddress {
last_caller::read()
}
}

If you have used Rust before, then the above code might look familiar, as Cairo 1.0 is heavily inspired by it. If you are unfamiliar with it, then starklings-cairo1 is a great place to start. Starklings is to Cairo what Rustlings is to Rust, a set of small, interactive exercises to help you learn the language.

The default type of variable in Cairo 1.0 is a field element called felt252, which is an integer in the range 0x<P0 \leq x < P, where PP is a very large prime P=2251+172192+1P = 2^{251} + 17 * 2^{192}+1. (For a more detailed explanation, see the felt-type section in The Cairo Programming Language book.) All other types in Cairo are built on top of felt252 such as the integer types of u8 to u256. It is recommended to use these higher-level types when possible as they provide additional safety features such as overflow protection.

When writing Starknet contracts, there are a few special attributes that are used to allow the compiler to generate the correct code. The #[contract] attribute is used to define a Starknet contract, similar to the contract keyword in Solidity.

A contract may be required to interact with another contract or to have some knowledge about the current execution state (for example, the caller address). This is where system calls come in, which allow the contract to interact with and use services from the Starknet OS. Most of the time the system calls are abstracted away or hidden behind helper methods, but you can see a list of the available system calls here.

The #[event] attribute is used to define an event that can be emitted by the contract. Similar to Solidity, events are used to notify the outside world of state changes in the contract and are emitted using the emit_event_syscall system call or by calling the helper function generated by the compiler, which is annotated with the #[event] attribute.

The #[external] attribute is used to define a function that can be called by the outside world, similar to the external keyword in Solidity. The #[view] attribute is designed to indicate that a function does not modify the contract state, although this is not enforced by the compiler, so state changes are possible if the function is called on chain.

The struct Storage is a special struct that the compiler uses to generate helper methods for interacting with the contract’s storage using the low-level system calls storage_read_syscall and storage_write_syscall. In a Starknet contract, the storage is a map of 22512^{251} slots that can each be read or modified. Each slot is a felt that is initially set to 0. The fields in the Storage struct are turned into modules with read and write methods that automatically calculate the correct location in the storage map (see here for how the address is calculated) and can be used to read and write to the storage.

Before you can deploy a contract on Starknet, the contract class must first be declared on the network. Each declared class on the network is represented by a clash hash (see here for how the hash is calculated), which uniquely identifies it and can be used to deploy new contract instances.

Beyond Ethereum: Starknet Accounts

Unlike Ethereum, Starknet does not have externally owned accounts (EOAs). Instead, accounts are special contracts that can define their own logic and rules. Here is the interface for a generic account contract:

#[account_contract]
mod Account {
use starknet::ContractAddress;

#[constructor]
fn constructor(public_key_: felt252);

fn isValidSignature() -> felt252;

#[external]
fn __validate_deploy__(
class_hash: felt252, contract_address_salt: felt252, public_key_: felt252
) -> felt252;

#[external]
fn __validate_declare__(class_hash: felt252) -> felt252;

#[external]
fn __validate__(
contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array<felt252>
) -> felt252;

#[external]
#[raw_output]
fn __execute__(mut calls: Array<Call>) -> Span<felt252>;
}

For a contract to be a valid account, it must at least implement the __validate__ and __execute__ functions and optionally implement the others. The __validate__ function should ensure that the transaction was initiated by the account owner, and the __execute__ function will perform the remaining actions. (See the “Validate and execute” section of the Starknet documentation for more details.)

The implementation could be as simple as checking an ECDSA signature, or it could be anything from a multi-sig to allowing multicalls. For a more in-depth look at accounts, see the Starknet documentation, the chapter “Account Abstraction” in The Starknet Book, and OpenZepplin’s account implementation.

Potential Pitfalls in Cairo

One of the main benefits of Starknet being a zkRollup is that contracts written in Cairo allow for execution traces to be proved and verified on Ethereum L1. It has been designed to provide flexibility, but this also could lead to insecure code. In this section, we will look at some potential pitfalls.

Overflows

When using integer types such as u128 and u256, there is now some nice built-in overflow protection that will cause a panic --- for example,

    let a: u128 = 0xffffffffffffffffffffffffffffffff;
let b: u128 = 1;
let c: u128 = a + b;

// Run panicked with [39878429859757942499084499860145094553463 ('u128_add Overflow'), ].

This is not the case when using felts directly as overflows are still possible:

    let a: felt252 = 0x800000000000011000000000000000000000000000000000000000000000000;
let b: felt252 = 1;
let c: felt252 = a + b;
c.print();

// [DEBUG] (raw: 0)

Reentrancy

If you mark a trait with the #[abi] attribute, then the compiler will automatically generate two dispatchers based on the trait name; for example, for the trait ICallback, the generated names will be ICallbackDispatcher and ICallbackLibraryDispatcher. A dispatcher is a simple struct that wraps the call_contract syscall, allowing you to call other contracts. The library dispatcher is different in that the current contract’s context and storage will be used when executing the external code, similar to delegatecall in Solidity. (See The Cairo Programming Language’s section on dispatchers for more details.)

Since the contract dispatcher passes control to the external contract, it is possible for the external contract to call back into the current contract, which could lead to reentrancy bugs. For example, consider the following contract:

#[abi]
trait ICallback {
#[external]
fn callback();
}

#[contract]
mod reentrancy {
use option::OptionTrait;

use starknet::get_caller_address;
use starknet::ContractAddress;
use super::ICallbackDispatcher;
use super::ICallbackDispatcherTrait;


struct Storage {
balances: LegacyMap::<ContractAddress, u256>,
claimed: LegacyMap::<ContractAddress, bool>,
}

#[external]
fn claim(callback: ContractAddress) {
let caller = get_caller_address();
if !claimed::read(caller) {
ICallbackDispatcher { contract_address: callback }.callback();
balances::write(caller, balances::read(caller) + 100);
claimed::write(caller, true);
}
}

#[external]
fn transfer(to: ContractAddress, amount: u256) {
let caller = get_caller_address();
balances::write(caller, balances::read(caller) - amount);
balances::write(to, balances::read(to) + amount);
}

#[view]
fn get_balance(addr: ContractAddress) -> u256 {
balances::read(addr)
}
}

The claim function allows a user to claim 100 tokens from the contract if they have not already claimed them, but since the callback happens before the state is updated, it’s possible for a contract to call the claim function repeatedly and claim as many tokens as they want:

use starknet::ContractAddress;

#[abi]
trait IClaim {
#[external]
fn claim(callback: ContractAddress);
}

#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::get_contract_address;

use super::IClaimDispatcher;
use super::IClaimDispatcherTrait;

struct Storage {
count: u256,
}

#[external]
fn callback() {
if (count::read() < 10) {
count::write(count::read() + 1);
IClaimDispatcher { contract_address: get_caller_address() }.claim(get_contract_address());
} else {
count::write(0);
}
}
}

When using the library dispatcher, you must provide a class hash instead of a contract address, so you cannot accidentally use the wrong dispatcher. The executed code will use the same context and storage as the current contract, so the class hash must be trusted.

Storage Clashes

When using the storage struct, the underlying address of the storage slot is calculated using sn_keccak(variable_name) (sn_keccak is the first 250 bits of the Keccak256 hash). If you are using other modules or external libraries that have a similar storage struct, then it’s possible for the storage slots to be identical and overwrite each other. For example, consider the following contract:

// foo.cairo
#[contract]
mod foo {
struct Storage {
num: u256,
}

fn get_num() -> u256 {
num::read()
}

fn set_num(n: u256) {
num::write(n);
}

}


// bar.cairo
use super::foo::foo;

#[contract]
mod bar {
struct Storage {
num: u128,
}

#[external]
fn set_num(n: u128) {
num::write(n)
}

#[view]
fn get_num() -> u128 {
num::read()
}

#[view]
fn foo_get_num() -> u256 {
super::foo::get_num()
}

#[external]
fn foo_set_num(n: u256) {
super::foo::set_num(n);
}
}

Both of the setters are writing to the same storage slot, except one is expecting a u256 and the other a u128, so when calling set_num, the bottom 128 bits of num will be set and the top 128 bits will not be changed. For example, if we call foo_set_num with 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, then set_num with 0x1234 then foo_get_num, we get the following output:

starknet call --address 0x005e942196b3e1adfac0e1d2664d69671188237db343067ab61048e63957487c --function foo_get_num
4660 0xffffffffffffffffffffffffffffffff

The Next Chapter: Cairo 2.0

Even though 1.0 has only just been released, the language keeps evolving, and 2.0 will be released soon. The majority of code will be compatible, and there will be a six-month period where both syntaxes will be valid. The main incoming changes are designed to allow the compiler to enforce that view functions do not modify the state of the contract, making it much more explicit and easier to know if a function will modify the state. The new interface syntax will be something similar to the following:

#[starknet::interface]
trait ICounterContract<TContractState> {
fn increase_counter(ref self: TContractState, amount: u128);
fn decrease_counter(ref self: TContractState, amount: u128);
fn get_counter(self: @TContractState) -> u128;
}

The trait for a Starknet interface is now required to explicitly state whether it requires a ref to the contract state, which allows it to be modified and implicitly returned at the end of a function, whereas the ”@” symbol indicates that the contract state is an immutable snapshot and cannot be modified. For more information, see the full details of the upcoming changes at the official community post.

Cairo: The Verdict

The changes from Cairo 0 to 1.0 are a great step towards making the language easy to use and adding some nice security features. The upcoming changes in Cairo 2.0 will make it easier to reason about where the state is modified and help catch mistakes earlier. However, it is still possible to write insecure code, and so it is important to understand the underlying system, the potential pitfalls, and how to avoid them.

Digging Deeper: Cairo Resources

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.