An introduction to Cairo 1 smart-contracts security

Antoine Mecker
9 min readApr 7, 2023

--

Bonjour bonjour dear Pharaohs, auditors and smart-contracts developers.

With the born of the new best smart-contracts programming language aka Cairo 1 (obvsly 100% objective take) and all the insane work from the Starkware team and the community (see for instance Madara), I see no reasons why would Starknet’s TVL not increase exponentially in the next few months. One could argue that it’s difficult to learn Cairo or that developers don’t want to learn a new language. I don’t think that is true. I don’t think the step for solidity developers to learn Cairo is high (and anyway the job of a dev is basically asking things to ChatGPT). Cairo is a wonderful playground for devs with for instance native account abstraction. Some people are even doing Machine Learning in Cairo !

So, as more and more devs will come, they will build new projects and those won’t skip the almost mandatory security check.

In this article, I will go through famous L1 vulnerabilities and try to apply them to Cairo and Starknet. I’m allowing myself to also use this article as my notebook so for each vulnerabilities I’ll write a brief explanation of it. I will not talk about Cairo’s inner security (see Eni’s article here).

The non-exhaustive list of vulns I’ve chosen is the following:

  1. Access control
  2. Tx.origin — msg.sender
  3. Overflows/underflows
  4. Reentrancy
  5. Proxy storage collision
  6. Privacy illusion
  7. delegatecall, call

Are these vulnerabilities applicable to Cairo or are the funds safu ?

1. Access control

This vulnerability obviously exists in Cairo.

Access control is basically configuring who can access what or who can call this or that function. You don’t really want any users to be able to call the function withdrawEveryFunds() right ?
The common solution for that issue is performing a check on the address of the person/entity (it could be a smart-contract) calling the function.
Something like this:

function withdrawFunds() {
IF addressCalling IS whitelisted THEN:
withdraw()
}

In my example I used the if keyword. In practice we don’t use that. Instead we prefer to use special keywords, in solidity we use require while in Cairo we would use assert . We don’t use if because we want the computation to stop if the condition isn’t met.
Solidity:

function withdrawFunds() external{
require(msg.sender == whitelistedAddress, "caller not whitelisted");
withdraw(msg.sender);
return true;
}

Cairo:


fn withdrawFunds() {
let caller = get_caller_address();
assert(caller == whitelistedAddress, 'caller not whitelisted');
}

See ? It’s almost the same thing.
I’ll talk about msg.sender and get_caller_address latter (see .2).

Also, in Solidity developers often use modifiers to apply their conditions to the functions. This feature doesn’t exist in Cairo but I’ve seen devs using internal functions that play that role.

You can find a list of Cairo’s bool operators here.

2. Tx.origin — msg.sender issue

No tx.origin in cairo, funds are safu

This vulnerability is linked to the Access Control issue I explained previously. It refers to a misunderstanding or misuse of the tx.origin or msg.sender keywords in solidity.

tx.origin refers to the account that initially triggered the transaction. msg.sender is the account where the curent call had been initiated from.

It easier to understand when you think about a user invoking some smart-contract function which then performs an external call to a smart-contract B. In this case, from contract B POV,tx.origin is the user’s address and msg.sender will be the smart-contract address.
This image explains this concept better:

The use of tx.origin can make a contract vulnerable to phishing attacks (see Ethernaut Telephone for a good example).

Does this issue exist in Cairo ?

The syntax in Cairo is different. tx.origin doesn’t exist at all. It removes the entire problem.

To get the caller address, you have to use the syscall get_caller_address.

use starknet::get_caller_address;
let caller = get_caller_address();

Sometimes, solidity devs use require(tx.origin==msg.sender) to ensure the transaction has been triggered by an EOA. You can’t do that in Cairo as there is native account abstraction but I’m sure people will find a way to do that check.

> I NEED TO CORRECT SOMETHING HERE.

devnet0x kindly made me notice that there is in fact a way to get tx.origin , you have to do something like:

let tx_info = starknet::get_tx_info().unbox();
let origin: ContractAddress = tx_info.account_contract_address;

3. Overflows/underflows

As solidity > 0.8, Cairo can deal with overflow and underflows without external libraries.

Overflows and underflows are all about “how much can a variable with some integer type hold data”. “some integer type” refers to the little number you can find at the end of the type name, los famosos uint8 or uint256 in solidityor u8, u256, felt252 in cairo. These variable types can only contain a certain amount of bits. For instance a u256 can contains 256 bits numbers which means the highest number that can fit in a u256 is 2^256–1 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 , which is a big ass number. We usually prefer writing u256 numbers in hex, 2^256–1 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffff .
It is easier to talk about overflows or underflows using smaller integer types like u8.
Applying the same logic, the highest u8 number is: 2⁸ — 1= 255. Let’s say I have a number let a : u8 = 255 . What would happen if I increased a by one ? It is called an overflow. An overflow is when a number is increased beyond its maximum capacity. An underflow is the exact opposite. In solidity, doing:

uin8 a = 255;
++a;

Would lead to a = 0 and doing this:

uint8 a = 0;
--a;

would result in a = 255.

In solidity to solve this kind of issues, one would probably use OpenZeppelin’s SafeMaths library which protects the code against operations overflows and underflows.
Also, latest versions of the solidity compiler contain an over/underflow protection by default where transactions are reverted.

What about Cairo ?

New Cairo’s DNA is entirely Rust based meaning error management is a crucial part of it. Cairo has a built-in over/underflow panicking system.

Look at this piece of code:

#[test]
fn test_add() {
// trying to sum 255_u8 and 1_u8
let res = 255_u8 + 1_u8;
}

The compiler instantly raises an error:

4. Reentrancy

The usual reentrancy with receive() or fallback() in solidity is not possible in Cairo. However, you can still find reentrancy patterns with external calls.

Even if reentrancy is one of the most famous vulnerability in web3 security, it still costs a lot of money to protocols and users.

A reentrancy attack can occur when a target/victim contract has a function making external calls to some contract. The other contract could call back the target before a state change/the end of the initial invocation.

If you want to read an example, you can click on one of the following links:
https://hackernoon.com/hack-solidity-reentrancy-attack ; https://swcregistry.io/docs/SWC-107 ; https://github.com/harendra-shakya/smart-contract-attack-vectors/blob/main/attack-vectors/Reentrancy.md ;

Does this vulnerability exist for Cairo contracts ?

The big difference is that ETH is an ERC20 and not a native currency like on L1. There is no integrated fallback feature regarding ETH. Hence something like this example is not possible. But that doesn’t mean reentrancy does not exist. As external calls are possible what would prevent a malicious contract reentering ?

reentrancy example

In this example, the target contract makes an external call to the caller contract. We can find a real life application of this example with ERC721 for instance. When contracts interact with ERC721, it is expected that they inherit from IERC721Receiver and that they implement the function onERC721Received() which is a callback function. The same thing happens for flashloans.

Developers have to be careful when their code perform external codes to untrusted contracts.

A solution is to implement a ReentrancyGuard. OZ chads wrote one for Cairo.

5. Proxy storage collision

Contracts data is stored differently between L1/EVM and Starknet/CairoVM. This vulnerability does not exist in the context of Starknet.

This is a fun and tricky one (imo). You obviously know that, by default, smart-contracts are immutable. While this might be good for some usecases, for others it is preferable to be able to change pieces of code (protocol improvements, fixing bugs etc etc). Smart-contracts devs have found a way to build upgradeable smart-contracts. The idea is simple, you split in 2 different contracts the logic and the storage, the implementation and the proxy.

The issue is raised from the way storage works with the EVM. Every storage slot is 256 bits/32 bytes. Several variables with a cumulated weight below 32 bytes can be stored in the same slot. The data is then put in an incremental slot position (position 0, then position 1, position2 etc etc).

The problem with upgradable smart-contracts with proxy is that proxy’s variables and implementation’s variables can overlap. The problem is well explained here.
The solution in solidity is to use randomized slots.

With the CairoVM things are different.

The storage address is computed based on the variable name, see here. Starknet has an integrated upgrade system. Each contract has a “class hash” and can have instances (think OOP class definition and instances). There is thesyscall replace_class_hash that allows developers to change their smart-contract class hash, hence upgrading it.

At the time of writing, we are in the middle of Cairo1 transition so it is still debated whether proxies are still needed with the new syscall. It will depend on the removal of the default entreypoint. I’m still not sure about the entire behaviour of replace_class_hash . I’ll update my article as soon as I have more infos.

The fact that slots are based on a hash of their name removes the issue. Moreover, the Cairo formatter forbids the use of two variables with the same name.

6. Privacy illusion

There are things that don’t change.

This one is not really a language vulnerability (for Solidity and Cairo) per se, it is more of an inherent feature of a blockchain. Everything is transparent, including the storage. I wanted to highlight the fact that Starknet is no different from Ethereum in this case.

An unexperienced developer might think that is it a good idea to “lock” its smart-contract’s functions using a password. The problem is that there is always a trace somewhere. For instance here is a contract where you can claim points if you know the secret (it is a cool tutorial btw). The secret is initiated during deployment and as you can see in the transaction logs

we can easily read it in the data.

7. Delegate call

Same idea but different execution

A delegate call is when some contract A wants to execute a function of a contract B but only in the context of contract A. I think of it as if contract A was borrowing contract B’s function code. This concept is well explained here. Is solidity, the delegatecall function could be used for instance for proxies or libraries. Delegatecall can cause issues if this concept is not understood properly as there can be storage collisions on L1.

The equivalent of delegatecall in Cairo is the system call library_call_syscall . You don’t interact with a contract directly but instead its code/class hash. There are 2 ways of doing that, either calling the syscall directly or using the dispatcher system.

The syscall signature looks like this (simplified):

library_call_syscall(class_hash, function_selector, calldata)

and the Dispatcher:

#[abi]
trait IAnotherContract {
fn foo(a: u128) -> u128;
}
#[contract]
mod TestContract {
use super::IAnotherContractDispatcherTrait;
use super::IAnotherContractDispatcher;
use super::IAnotherContractLibraryDispatcher;
#[external]
fn call_foo(another_contract_address: starknet::ContractAddress, a: u128) -> u128 {
IAnotherContractDispatcher { contract_address: another_contract_address }.foo(a)
}
#[external]
fn libcall_foo(a: u128) -> u128 {
IAnotherContractLibraryDispatcher { class_hash: starknet::class_hash_const::<0>() }.foo(a)
}
}

From the interface, a new trait is created in the backstage called IAnotherContractDispatcherTrait. This DispatcherTrait has 2 implementations, one for the contract calls IAnotherContractDispatcher and one for the library calls IAnotherContractLibraryDispatcher. Here you only use the logic of the class so there is no potential issue with storage collision.

This ends my first article on Cairo 1 security. My opinion is that Cairo is safer than Solidity (there is no experience feedback tho, we will see). The formatter offers, by default, a great protection. But of course, auditing tools like static analyzers will still be useful and it does not protect against blockchain/async systems/Defi related issues.

As I read on Twitter few weeks ago, Cairo audits will be more about finding high level vulns and deep understandings of protocols rather than gas optimization or dumbdumb language mistakes. I could not agree more.

I will write another article on specific Cairo issues. I still don’t know when but it’s on my todo.

Thanks for reading and thanks Eni from Nethermind & Gershon from GingerSec for the lil review.
I don’t think we are ready for the amazing future of Starknet.

--

--

Antoine Mecker
Antoine Mecker

Written by Antoine Mecker

A blockchain engineer learning smart-contracts security

Responses (1)