
Exploring Cairo: A Security Primer
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 , where is
a very large prime . (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
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.
*** This is a Security Bloggers Network syndicated blog from Zellic — Research Blog authored by Zellic — Research Blog. Read the original post at: https://www.zellic.io/blog/cairo-security-primer