How to hack (almost) any Starknet Cairo smart contract

Gershon Ballas
Ginger Security
Published in
5 min readNov 11, 2022

--

Disclaimer: This is not a hack in the underlying Starknet or Cairo architectures. Instead, it is a very common developer-introduced vulnerability that we’ve been seeing (and disclosing) in the wild. If you’re a Cairo developer — please read and understand this issue to prevent potential hacks in your dapps.

All of us around crypto know and love Starknet by Starkware… Vitalik himself seems to be a fan:

And it’s not for nothing. The Starkware team is absolute god-tier when it comes to ZK-scalability moon math. It’s the main reason their company has recently become an $8 bln behemoth and is the leading ZK-L2 technology by TVL (dYdX and Immutable X both run using Starkware tech).

However…………. when it comes to Cairo, the language used for writing Starknet contracts…. the Starkware team made a huge flop. And I mean HUGE.

Today kids, we’re gonna learn about how pretty much any Cairo contract is hackable (potentially).

The devil in the details — The Uint256 struct

Solidity has a number of native types developers can use:

  • bool
  • uint8 to uint256
  • address
  • bytes1 to bytes32
  • mapping

And a couple of others…

Cairo, on the other hand, has exactly one type — felt.

The felt is a 252-bit type that can be used for anything — signed/unsigned ints, booleans, addresses, and even CairoVM bytecode.

Why 252 bits? The reason for that has to do with Starkware STARK moon math… you can read more about it here.

So if you want to…

  • represent a bool — you use a felt
  • represent a uint8 — you use a felt
  • represent an address — you use a felt
  • you get the point…

But what if you want to represent a uint256? A felt is only 252 bits…

For that you’d need the Uint256 struct, defined as such (source):

A Uint256 struct is composed of two felts — one to represent the low 128 bits, and another to represent the high 128 bits.

Cairo Uint256 struct

So basically every Uint256 has 2*124 = 248 bits that are completely ignored and never get used.

But are they completely ignored? …. 👀

”Comparison is the thief of joy”

As you may have already guessed… those junk bits are not ignored.

Let’s look at how Uint256 comparisons work in Cairo.

Instead of using <, >, and == like a normal language would for its most used int types, Cairo uses these functions instead:

  • uint256_lt() for <
  • uint256_gt() for >
  • uint256_eq() for ==

Now let’s deep dive into the uint256_lt() func in order to see whether or not it really ignores those junk bits (source):

We can see that it calls the is_le() func (used for comparing felts, source):

I’ll save you the trouble of understanding how is_nn() works… what you need to know is that is_le() works as described — “Returns 1 if a <= b. Returns 0 otherwise.”

Hint — is_le() looks at the whole 252-bits of the felt, not just the first 128!!! 🤦‍♂️

Okay, okay… let’s backtrack a little

Let’s say we have two Uint256 structs, A and B.

A is:

  • A.low = 0
  • A.high = 1
  • A should represent 0 + 1*2¹²⁸ = 2¹²⁸ = 340282366920938463463374607431768211456

B is:

  • B.low = 1
  • B.high = 1
  • B should represent 1 + 1*2¹²⁸ = 1+2¹²⁸ = 340282366920938463463374607431768211457

(B is greater than A by 1).

Let’s run uint256_le(A, B):

  1. a.high == b.high is TRUE
  2. returning is_le(a.low + 1, b.low) evaluates to returning is_le(0 + 1, 1)
  3. is_le(0 + 1, 1) returns 1 (meaning TRUE)

So uint256_le(A, B) evaluates to TRUE, just as expected (since A < B).

So far so good 😌

But what if we gave the uint256_le() a malformed A? Specifically, an A whose low felt’s 129th bit has been turned on.

In this scenario, A_malformed is:

  • A_malformed.low = 2¹²⁹
  • A_malformed.high = 1
  • A_malformed should represent 0 + 1*2¹²⁸ = 2¹²⁸ = 340282366920938463463374607431768211456

B is:

  • B.low = 1
  • B.high = 1
  • B should represent 1 + 1*2¹²⁸ = 1+2¹²⁸ = 340282366920938463463374607431768211457

Running uint256_le(A_malformed, B) should produce the same result as uint256_le(A, B) (the junk bits in A_malformed should be ignored).

However, in reality:

  1. a.high == b.high is TRUE
  2. returning is_le(a.low + 1, b.low) evaluates to returning is_le(2¹²⁹ + 1, 1)
  3. is_le(2¹²⁹ + 1, 1) returns 0 (meaning FALSE)

It’s not the result we’d expect… 😨

Of course, the same trick (writing to the junk bits to f*ck up Uint256 comparisons) can be achieved by writing to the junk bits of the high felt.

So the Starkware team really flooked that one… Let’s see how we can exploit it.

Exploitation

The uint256_le() func is not the only one that is vulnerable to malformed inputs. Here is a partial list of other vulnerable functions:

uint256_add()

  • will not produce malformed output
  • output can be made to be much higher than it should be

uint256_mul()

  • output can be made to be much higher than it should be
  • may produce malformed output as well

uint256_sub()

  • like uint256_add()

uint256_lt()

  • result can be chosen either way via malformed input (as we’ve shown above)

uint256_eq()

  • func can be made to return FALSE even though inputs are equal

The savvy among you can already imagine how this may be used to $$$exploit$$$. But if you wanna test your skills, check out the cairo-auction challenge from the 2022 Paradigm CTF challenge. It may be solved using your newly-acquired knowledge.

Mitigation and vulnerabilities in the wild

Preventing malformed Uint256 vulnerabilities is fairly simple. Simply call the provided uint256_check() func on all Uint256 inputs and you’ll be fine.

Do Cairo devs actually do that? Mostly no.

We have not seen the uint256_check() func called on inputs in 100% of the contracts that we were tasked with auditing so far. In some of them, this has led to medium-severity vulnerabilities (we will not disclose project names since some of them are still being patched).

And I wouldn’t blame the devs for that. This Uint256 malformation issue cannot be found anywhere in the Cairo docs.

With our eyes on the future

Luckily for us and for the Starkware team, Cairo 1.0 fixes all of that. In Cairo 1.0, Uint256 becomes a native type:

(I’m assuming this means the Uint256 malformation check will be built in, but you never know… 😅)

--

--