本文介绍了最近发布的Cairo 1.0,Starknet的原生编程语言,重点关注其安全特性以及在Starknet上编写合约时可能遇到的陷阱。通过具体的代码示例讲解了如何编写Starknet智能合约,并指出了潜在的安全问题,如溢出、重入和存储冲突等。作者还提到了Cairo 2.0即将发布的改变,旨在增强语言的易用性和安全性。
在本文中,我们将重点关注最近发布的 Cairo 1.0,这是 Starknet 的本地语言。我们将简要介绍 Cairo 和 Starknet,探索一些 Cairo 的安全特性,并查看在 Cairo 中编写合约时可能出现的一些陷阱。对于任何考虑在 Cairo 中编写合约的人来说,本文将为你提供一个起点以及在编写安全代码时需要考虑的一些事项。
Cairo 1.0 是一种受 Rust 启发的语言,旨在让任何人都能够创建 STARK 可证明的智能合约。它是 Starknet 的本地语言,Starknet 是一个旨在实现高吞吐量和低手续费的 zkRollup。本文将重点关注在 Starknet 上编写智能合约时使用的 Cairo 的安全特性。
首先,让我们通过一个用 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()
}
}
如果你之前使用过 Rust,那么上述代码可能看起来很熟悉,因为 Cairo 1.0 受其启发很深。如果你不熟悉它,那么 starklings-cairo1↗ 是一个很好的起点。Starklings 就像是 Cairo 的 Rustlings,是一组小的互动练习,帮助你学习这门语言。
在 Cairo 1.0 中,默认的变量类型是称为 felt252
的场元素,它是在范围 0≤x<P0 \leq x < P0≤x<P 的整数,其中 P 是一个非常大的素数,P=2251+17∗2192+1P = 2^{251} + 17 * 2^{192}+1P=2251+17∗2192+1。 (有关更详细的说明,请参见 The Cairo Programming Language 书中的 felt-type\ section↗。)Cairo 中的所有其他类型都是基于 felt252
的,例如整数类型 u8
到 u256
。建议在可能的情况下使用这些更高级别的类型,因为它们提供了额外的安全特性,例如溢出保护。
在编写 Starknet 合约时,有一些特殊属性用于允许编译器生成正确的代码。#[contract]
属性用于定义一个 Starknet 合约,类似于 Solidity 中的 contract
关键字。
一个合约可能需要与另一个合约互动,或者对当前执行状态(例如,调用者地址)有一定的了解。这就是系统调用的来源,它们允许合约与 Starknet OS 进行交互并使用服务。大多数时候,系统调用被抽象或者隐藏在助手方法后面,但你可以在 此处↗ 查看可用的系统调用列表。
#[event]
属性用于定义合约可以发出的事件。与 Solidity 类似,事件用于通知外部世界合约中的状态变化,并通过 emit_event_syscall
系统调用或调用由编译器生成的带有 #[event]
属性的助手函数来发出。
#[external]
属性用于定义一个可以被外部调用的函数,类似于 Solidity 中的 external
关键字。#[view]
属性旨在指示一个函数不修改合约状态,尽管编译器不强制执行这一点,因此如果在链上调用该函数,则可能会发生状态变化。
struct Storage
是一个特殊的结构,编译器用它生成与合约存储进行交互的助手方法,使用底层系统调用 storage_read_syscall
和 storage_write_syscall
。在 Starknet 合约中,存储是一个 2251 slots 的映射,每个 slot 可以被读取或修改。每个 slot 是一个 felt,初始值为 0。Storage
结构中的字段会被转化为具有 read
和 write
方法的模块,这些方法会自动计算在存储映射中的正确位置(有关地址是如何计算的,请查看 这里↗),并可以用来读取和写入存储。
在你可以在 Starknet 上部署合约之前,合约类必须首先在网络上声明。每个在网络上被声明的类由一个类哈希表示(有关哈希计算方式,请参见 此处↗),它唯一标识该类并可用于部署新的合约实例。
与以太坊不同,Starknet 没有外部拥有账户(EOAs)。相反,账户是特殊的合约,可以定义自己的逻辑和规则。下面是一个通用账户合约的接口:
#[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>;
}
为了使一个合约成为有效账户,它必须至少实现 __validate__
和 __execute__
函数,并可选地实现其他函数。__validate__
函数应确保交易是由账户所有者发起的,而 __execute__
函数将执行剩余操作。(有关更多详细信息,请参见 Starknet 文档中的 “验证和执行↗”部分。)
实现可以是简单地检查 ECDSA 签名,或者是从多签到允许多次调用的任何内容。有关账户的深入了解,请参见 Starknet 文档↗,以及《The Starknet Book》中的 “账户抽象↗”一章,和 OpenZeppelin 的 账户实现↗。
Starknet 作为 zkRollup 的主要好处之一是用 Cairo 编写的合约允许在以太坊 L1 上证明和验证执行轨迹。它旨在提供灵活性,但这也可能导致不安全的代码。在本节中,我们将查看一些潜在的陷阱。
当使用诸如 u128
和 u256
的整数类型时,提供了一些内置的溢出保护,这会导致程序崩溃——例如,
let a: u128 = 0xffffffffffffffffffffffffffffffff;
let b: u128 = 1;
let c: u128 = a + b;
// 运行时崩溃,带有 [39878429859757942499084499860145094553463 ('u128_add Overflow'), ]。
当直接使用 felts 时情况并非如此,因为仍然可能会发生溢出:
let a: felt252 = 0x800000000000011000000000000000000000000000000000000000000000000;
let b: felt252 = 1;
let c: felt252 = a + b;
c.print();
// [DEBUG] (raw: 0)
如果你用 #[abi]
属性标记一个特征,那么编译器将基于特征名称自动生成两个调度程序;例如,对于特征 ICallback
,生成的名称将是 ICallbackDispatcher
和 ICallbackLibraryDispatcher
。调度程序是一个简单的结构,包装了 call_contract
syscall,允许你调用其他合约。库调度程序不同之处在于,执行外部代码时将使用当前合约的上下文和存储,类似于 Solidity 中的 delegatecall
。(有关更多详细信息,请参见 The Cairo Programming Language 关于 调度程序↗ 的部分。)
由于合约调度程序将控制权交给外部合约,因此外部合约有可能回调到当前合约,这可能导致重入错误。例如,考虑以下合约:
#[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)
}
}
claim
函数允许用户从合约中索取 100 个代币,如果他们尚未索取过,但由于回调发生在状态更新之前,因此合约可以多次调用 claim
函数来索取任意数量的代币:
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);
}
}
}
使用库调度程序时,你必须提供类哈希而不是合约地址,因此你不会意外使用错误的调度程序。执行的代码将使用与当前合约相同的上下文和存储,因此必须值得信任类哈希。
在使用存储结构时,存储槽的基础地址是使用 sn_keccak(variable_name)
计算的(sn_keccak
是 Keccak256
哈希的前 250 位)。如果你使用其他模块或外部库,它们具有类似的存储结构,那么存储槽可能会相同并互相覆盖。例如,考虑以下合约:
// 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);
}
}
这两个设置都正在写入同一个存储槽,除了一个期望是 u256
另一个是 u128
,因此当调用 set_num
时,num
的底部 128 位将被设置,而顶部 128 位将保持不变。例如,如果我们用 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
调用 foo_set_num
,然后用 0x1234
调用 set_num
,最后调用 foo_get_num
,我们会得到以下输出:
starknet call --address 0x005e942196b3e1adfac0e1d2664d69671188237db343067ab61048e63957487c --function foo_get_num
4660 0xffffffffffffffffffffffffffffffff
尽管 1.0 刚刚发布,但该语言仍在不断演变,2.0 将很快发布。大多数代码将兼容,并将有一个六个月的过渡期,在此期间两种语法都有效。即将到来的主要变化旨在让编译器强制执行 view
函数不修改合约状态,使得更清晰、更容易知道一个函数是否会修改状态。新的接口语法将类似于以下内容:
#[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;
}
Starknet 接口的特征现在要求明确说明它是否需要对合约状态的 ref
,这允许它被修改并在函数结束时隐式返回,而 @
符号表示合约状态是不可变快照,无法被修改。有关即将更改的完整详细信息,请参阅 官方社区文章↗。
Cairo 0 到 1.0 的变化是向使语言易于使用和添加一些良好的安全特性迈出的重要一步。即将在 Cairo 2.0 中的更改将使了解状态修改的位置更容易,并帮助你更早地发现错误。然而,编写不安全的代码仍然是可能的,因此了解底层系统、潜在的陷阱以及如何避免它们是非常重要的。
Zellic 专注于保护新兴技术。我们的安全研究人员已在最有价值的目标中发现了漏洞,从财富 500 强到 DeFi 巨头。
开发人员、创始人和投资者信任我们的安全评估,以快速、自信且没有重大漏洞地推出新产品。凭借我们在现实世界的攻击安全研究中的背景,我们发现了他人遗漏的问题。
联系我们↗ 以获得更好的审计。真正的审计,而不是走过场的签字。
- 原文链接: zellic.io/blog/cairo-sec...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!