Cairo Security Flaws

16 August, 2024
article image
Expert Insights

Contents

Intro

StarkNet is a scalable Layer 2 solution for Ethereum, built on zk-STARK technology. It enables fast, secure, and low-cost transactions through the use of validity rollups. StarkNet operates on the Cairo VM, allowing for the development of smart contracts in the Cairo language.

While Cairo is a relatively new language, it is rapidly evolving and gaining popularity. However, as with any new technology, security concerns require careful examination.

Cairo Basics

Let’s start our dive into the world of Cairo by exploring its fundamentals. This will allow us to more effectively absorb subsequent information.

Primary Data Type

The primary data type in Cairo is a felt. It represents an integer whose value lies in the range from 0 to P-1, where P is a fixed large prime number equal to 2^251 + 17 * 2^192 + 1 bits.

Memory

Cairo employs a read-only, non-deterministic memory model. Each memory cell can be written to only once and its value cannot be modified during program execution. The syntax [x] represents the value at memory address x. This model implies that if a value is set at the beginning of a program, it remains constant throughout its execution.

Modifiers

Cairo employs the keyword fn to define functions. While this language supports parameter modifiers (such as mut for mutable and ref for reference parameters), it does not provide a mechanism for adding modifiers to the function itself. Unlike Solidity, which uses modifiers like external or view to influence function visibility and behavior, Cairo lacks such modifiers.

Want to dive even deeper into Cairo? The official documentation is your go-to guide.

Security Practices

Having explored the fundamental elements of Cairo, we will now turn our attention to analyzing the most common attack vectors and vulnerabilities inherent to this language. We will also discuss methods to mitigate these issues.

L1 L2 Type Conversion

Let’s illustrate this problem using the example of addresses. As previously mentioned, Starknet addresses are of type felt, which has a range of 0 < x < P (2^251 + 17 * 2^192 + 1 bits). In Ethereum L1, addresses are of type uint160, with a range of 0 <= x < 2^160.

When transferring addresses between layers from L1 to L2, the address is typically represented as uint256. However, this can lead to issues where a valid L1 address is mapped to a null address or an unexpected address on L2.

For instance, consider an L1-to-L2 bridge contract that allows depositing tokens from L1 to L2. If the to address parameter is not properly validated, it could result in tokens being sent to an unintended address on L2.

To mitigate this, it is crucial to validate parameters, especially those provided by users, when sending messages from L1 to L2. It is important to remember that the range of the felt type in Cairo is smaller than the range of the uint256 type used in Solidity.

Remember that this issue is relevant to all uint256 values when creating and verifying code.

Access Control

An incorrect implementation of access control can lead to serious consequences. The compromise of a single account as a result of an attack can lead to the compromise of the entire smart contract and, consequently, the loss of funds stored within.

A standard security mechanism is to verify the address of the calling function. In Solidity, the requireoperator is typically used for this, causing the transaction to revert if the conditions are not met. In Cairo, the assert operator plays a similar role.

Let’s consider a withdrawAssets function that includes a check of the caller’s address to execute the function.

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

To effectively implement access control in StarkNet contracts, it is recommended to use the OpenZeppelin library. It provides pre-built contracts, such as Ownable and AccessControl, that allow for flexible definition of roles and management of access to contract functions.

However, when designing an access control system, the principle of least privilege should be adhered to. This means that each role should be granted only those permissions that are strictly necessary for its functions. Excessive permissions can create vulnerabilities and increase the risk of contract compromise.

Reentrancy

While reentrancy attacks have become less frequent due to the introduction of various protection methods and increased developer awareness, they are still possible in StarkNet.

OpenZeppelin developers have implemented a reentrancy guard mechanism here as well, which protects critical contract functions from unauthorized re-entries.

Unlike Solidity, Cairo does not support function modifiers. For this reason, the OpenZeppelin library offers two methods: _start and _end, which must be called at the beginning and end of the protected function, respectively.

    func _start{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {
        let (has_entered) = ReentrancyGuard_entered.read();
        with_attr error_message("ReentrancyGuard: reentrant call") {
            assert has_entered = FALSE;
        }
        ReentrancyGuard_entered.write(TRUE);
        return ();
    }

    func _end{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() {
        ReentrancyGuard_entered.write(FALSE);
        return ();
    }

The protected function should call ReentrancyGuard._start before the first statement of the function and ReentrancyGuard._end before the return statement. If the function is reentered, ReentrancyGuard._start will be called again, and upon subsequent access to storage, it will see that has_entered is now TRUE, causing the assertion to fail.

Furthermore, developers should always remember to adhere to the checks-effects-interaction pattern, which also helps prevent this type of attack.

Repeat Signature Replay

The key takeaway from this attack is the critical importance of adhering to the principle of nonce uniqueness. For every transaction, especially those involving signatures, the nonce must be strictly incremented. This implies that the signature infrastructure must request the current nonce value before generating each new signature.

Let’s imagine you’ve staked your funds in a protocol and have accumulated rewards. To withdraw these rewards, you need to confirm the transaction with a valid signature. The signature verification function takes the amount, r, and s values of the signature as input. If the signature is valid, it will retrieve the address of the ecosystem token and transfer the amount to the calling subscriber.

However, an attacker could replay the transaction and call this function as many times as desired, as long as the amount parameter matches the original signed message. To prevent this, we add a nonce value to the function.

@external
func swap_game_currency_safe{
    syscall_ptr : felt*,
    pedersen_ptr : HashBuiltin*,
    range_check_ptr,
    ecdsa_ptr : SignatureBuiltin*
    }(
        r: felt,
        s: felt,
        amount: felt
    ):
    alloc_locals
    let (local caller_address) = get_caller_address()
    let (local nonce) = nonces.read(caller_address)
    let (_signer) = signer.read()

    # update nonce
    nonces.write(caller_address, value=nonce+1)

    let (message) = hash2{hash_ptr=pedersen_ptr}(amount, caller_address)
    let (message_part_2) = hash2{hash_ptr=pedersen_ptr}(message, nonce)

    verify_ecdsa_signature(
        message=message_part_2, public_key=_signer, signature_r=r, signature_s=s
    )

    let (token_address_) = token_address.read()
    let (contract_address) = get_contract_address()

    IERC20.transferFrom(contract_address=token_address_, sender=contract_address, recipient=caller_address, amount=Uint256(amount, 0))

    return ()

    end

Now, for each function call, the user’s current nonce is retrieved from the storage and incremented by 1, so that the next call uses the updated value.

Upgradable Contracts

Unlike Ethereum, StarkNet distinguishes between contract classes and contract instances. A contract class encapsulates immutable bytecode representing the contract’s logic, while a contract instance is a specific deployment of a contract class with its own unique state.

A common pitfall in StarkNet development is the inadvertent direct deployment of an implementation contract, circumventing the proxy contract. To correctly implement the proxy pattern in StarkNet, the following steps should be followed:

  1. Define a contract class containing the core logic of the smart contract.
  2. Deploy a proxy contract that will forward all calls to the implementation contract.
  3. Initialize the implementation contract through the proxy contract, passing the necessary parameters.

Deviation from this pattern undermines the core purpose of the proxy pattern: providing abstraction and centralized control over contract state access. Bypassing the proxy allows for arbitrary modifications to the implementation contract’s state, potentially leading to unforeseen consequences and compromising system security.

To simplify working with contracts that have initialization functions, it is also recommended to use the OpenZeppelin’s Initializable library. It provides convenient tools for the safe and reliable management of contract lifecycles.

Conclusion

Ensuring security is a paramount concern for both developers and auditors. Cairo, as a relatively new programming language, also necessitates a thorough analysis in terms of reliability and the identification of potential vulnerabilities.

Telegram
Expert Insights

Contents

Telegram

Have a question?

Have a question?

Stay Connected with OXORIO

We're here to help and guide you through any inquiries you might have about blockchain security and audits.