本文介绍了Aleo区块链平台及其上的Leo编程语言,Leo专注于开发具有强大隐私性的应用程序。文章详细阐述了Leo语言的基础知识,包括其语法、记录模型以及与链上状态的交互方式。此外,文章还探讨了Aleo程序中可能存在的潜在漏洞,例如整数溢出、未防护的程序初始化、Record的消费限制以及函数中信息泄露,并提供了避免这些漏洞的最佳实践。
Aleo 是一个区块链平台,它利用零知识密码学来实现私有且可扩展的去中心化应用。Leo 是 Aleo 的核心,是一种专为开发私有应用程序量身定制的高级编程语言。Leo 允许开发人员专注于创建具有强大隐私性的应用程序,而无需考虑零知识证明的复杂性。
理解和利用 Leo 的独特功能对于旨在构建强大且安全解决方案的开发人员至关重要。本文简要介绍了 Leo,重点介绍了它的安全特性和开发人员的实用技巧。它旨在帮助开发人员了解如何利用 Leo 的功能在 Aleo 上编写更安全的程序。本文中的所有程序均以 Leo 2.1.0 版本编写。
Leo 是一种静态类型语言,其语法与 Rust 类似。以下是一个示例程序,可让你对 Leo 语言有一个初步的印象:
program example_program1.aleo {
mapping data_map: address => u8;
// transition 在链下执行
async transition save_sum(private a: u8, private b: u8) -> (u8, Future) {
let sum: u8 = a + b;
return (sum, finalize_save_sum(self.caller, sum));
}
// finalize 在链上执行
async function finalize_save_sum(caller: address, sum: u8) {
data_map.set(caller, sum);
}
}
Leo 的语法非常简单。在上面的 example_program1.aleo
程序中,它定义了一个映射 data_map
,该映射存储的键的类型为 address
,值的类型为 u8
。在 save_sum
transition 中,它接受两个输入,计算总和并将其传递给 finalize_save_sum
函数。然后,在 finalize_save_sum
函数中,它将总和值设置为 data_map
,键为调用者地址。
Leo 的独特之处在于它分离了 transition
和 function
。transition
在链下以私有方式执行。执行的正确性由零知识证明来证明,并由网络验证。默认情况下,所有输入(a
和 b
)和中间变量都是隐藏的。function
在链上公开执行,以便它可以读取和写入链上的公共状态。
由于 transition
只能在链下执行,因此它无法接触链上状态。相反,它可以创建和使用 Record
,后者封装了合约的状态和数据。Record
是一种特殊的结构体,表示 Aleo 上的 UTXO 数据模型。Record
可以具有自定义的字段。私有字段会被加密并存储在账本上。只有创建者和所有者才能解密私有字段。transition
可以通过输出 record 来创建 record,并通过输入 record 来使用 record。
program example_program2.aleo {
record Token {
owner: address,
amount: u128
}
transition private_transfer_token(private receiver: address, private token: Token) -> Token {
let new_token: Token = Token {
owner: receiver,
amount: token.amount
};
return new_token;
}
}
在上面的代码中,它定义了一个名为 Token
的 record,其中包含 owner
和 amount
字段。private_transfer_token
transition 接受 Token
record 作为输入,使用它,并输出一个具有相同金额和新所有者的新 token。默认情况下,Token
record 中的所有字段都是私有的。由于 private_transfer_token
是在链下执行的,并且输入和输出是私有的,因此其他人不会知道发送者、接收者和金额。
Aleo 为构建私有应用程序提供了简单而强大的原语。尽管如此,如果不小心,它可能会引入不直观的行为和漏洞。在本节中,我们来看看 Aleo 程序的编码模式和潜在漏洞。
对于整数类型(例如,i8
和 u8
),Leo 中始终会捕获下溢和溢出。在 transition
中,如果发生下溢或溢出,证明者将无法创建证明。在 function
中,如果发生这种情况,整个交易将被回滚。
例如,在前面的 example_program1.aleo
程序中,如果我们使用 128u8
和 128u8
运行 save_sum
,它将发生 panic。
$ leo run save_sum 128u8 128u8
Leo ✅ Compiled 'example_program1.aleo' into Aleo instructions
Failed constraint at :
(256 * 1) != 0
Error [ECLI0377012]: Failed to execute the `run` command.
Error: 'example_program1.aleo/save_sum' is not satisfied on the given inputs (14652 constraints).
此外,不可能的强制转换会导致 panic:
// 输入 a 为 256 会发生 panic,因为它无法转换
transition cast_number(private a: u32) -> u8 {
return a as u8;
}
对于 field
类型,不会发生下溢和溢出,因为它是模运算。
program example_program3.aleo {
transition sub_field(private a: field, private b: field) -> field {
return a - b;
}
}
使用 sub_field(0field,1field)
运行上面的程序将获得模数结果:
$ leo run sub_field 0field 1field
Leo ✅ Compiled 'example_program3.aleo' into Aleo instructions
⛓ Constraints
• 'example_program3.aleo/sub_field' - 0 constraints (called 1 time)
➡️ Output
• 8444461749428370424248824938781546531375899335154063827935233455917409239040field
Leo ✅ Finished 'example_program3.aleo/sub_field'
Leo 没有提供在部署后初始化程序的默认函数(例如 Solidity 中的 constructor
函数)。这意味着开发人员需要自己提供初始化函数。至关重要的是,初始化函数必须得到适当的保护,以防止未经授权的调用或重复调用。
在下面的程序中,initialize
可以被重复调用。然后任何人都可以调用该函数将自己设置为管理员,然后成功调用 mint
函数。
program example_program4.aleo {
const ADMIN_KEY: u8 = 0u8;
mapping admin: u8 => address;
// 这**不**安全!
async transition initialize() -> Future {
return finalize_initialize(self.caller);
}
async function finalize_initialize(caller: address) {
admin.set(ADMIN_KEY, caller);
}
async transition mint() -> Future {
return finalize_mint(self.caller);
}
async function finalize_mint(caller: address) {
let current_admin: address = admin.get(ADMIN_KEY);
assert(current_admin == caller);
// mint token
}
}
在 Aleo 程序中,可以在程序 A 中定义 record,并将其传递到程序 B 中。但是,record 只能由定义它的程序(即程序 A)创建或使用。
例如,如果我们想实现一个程序,该程序销毁一个信用 record 并颁发证书,则以下程序将不会销毁信用 record。
import credits.aleo;
program example_program5.aleo {
record BurnerCertificate {
owner: address,
amount: u64
}
// credit 不会在此 transition 中被使用
transition burn(credit: credits.aleo/credits) -> BurnerCertificate {
let certificate: BurnerCertificate = BurnerCertificate {
owner: credit.owner,
amount: credit.microcredits
};
return certificate;
}
}
burn
transition 接受 credits.aleo/credits
作为输入。但是,信用 record 不会被 transition 使用,因为它不是在当前程序中定义的(即,信用 record 是一个外部 record)。
正确的方法是调用 credits.aleo
中的一个函数来销毁信用 record:
transition burn(credit: credits.aleo/credits) -> BurnerCertificate {
let certificate: BurnerCertificate = BurnerCertificate {
owner: credit.owner,
amount: credit.microcredits
};
// 调用 `credits.aleo/transfer_private` 来销毁 credit
credits.aleo/transfer_private(credit, ZERO_ADDRESS, credit.microcredits);
return certificate;
}
在上面的代码中,credit
record 被传递到 credits.aleo/transfer_private
中。record 将被销毁,因为 record 和函数是在同一个程序中定义的。
创建 Record 时,其私有字段使用所有者的地址密钥进行加密。当所有者使用 Record 时,需要使用所有者的私钥进行授权。由于程序没有私钥,因此它不可能使用 Record。这意味着转移到程序的任何 Record 都将丢失。
开发人员应谨慎处理此问题,以确保 record 不会被发送到程序。目前,没有直接的方法可以判断地址是否为程序地址,除非它需要所有者为 self.signer
。
在 Aleo 程序中,function
在链上执行并且完全公开。开发人员应该谨慎对待传递给 function
的内容。
下面的代码是一个可能泄露有关 Record 信息的示例:
program example_program6.aleo {
record Token {
owner: address,
amount: u128,
expire_at: u32
}
async transition transfer(private receiver: address, private token: Token) -> (Token, Future) {
let new_token: Token = Token {
owner: receiver,
amount: token.amount,
expire_at: token.expire_at
};
return (new_token, finalize_transfer(token.expire_at));
}
// 它可能会泄露信息,因为 `expire_at` 字段是公开的。
async function finalize_transfer(expire_at: u32) {
assert(expire_at > block.height);
}
}
在 transfer
transition 中,它会将 Token
的 expire_at
字段传递到 finalize_transfer
函数中。如果某些 record 具有不同的 expire_at
值,则其他人将能够推断出正在转移哪个 Token
。
缓解此问题的一种方法是避免将确切的 expire_at
字段传递给该函数。相反,我们可以输入一个中间值 intermediate_height
并确保 intermediate_height < height
并且 token.expire_at <= intermediate_height
在 transition 中:
program example_program6.aleo {
record Token {
owner: address,
amount: u128,
expire_at: u32
}
async transition transfer(private receiver: address, private token: Token, intermediate_height: u32) -> (Token, Future) {
assert(token.expire_at >= intermediate_height);
let new_token: Token = Token {
owner: receiver,
amount: token.amount,
expire_at: token.expire_at
};
return (new_token, finalize_transfer(intermediate_height));
}
// 仅在公共函数中比较 `intermediate_height`
async function finalize_transfer(intermediate_height: u32) {
assert(intermediate_height > block.height);
}
}
Leo 支持三元条件运算符。但是,该条件将评估双方。考虑以下代码来计算 sub 的绝对值:
program example_program7.aleo {
transition abs_sub(private a: u32, private b: u32) -> u32 {
return a > b ? a - b : b - a;
}
}
当 a != b
时,此函数将始终 panic。这是因为 a - b
和 b - a
都将被评估,并且其中一个将下溢:
$ leo run abs_sub 2u32 1u32
Leo ✅ Compiled 'example_program7.aleo' into Aleo instructions
Failed constraint at :
(0 * 1) != 1
Error [ECLI0377012]: Failed to execute the `run` command.
Error: 'example_program7.aleo/abs_sub' is not satisfied on the given inputs (13834 constraints).
为了避免下溢,我们可以将无符号整数转换为有符号整数。然后,将结果强制转换回无符号整数:
transition abs_sub(private a: u32, private b: u32) -> u32 {
// 使用 i64 确保成功强制转换
let ai: i64 = a as i64;
let bi: i64 = b as i64;
let ci: i64 = ai > bi ? ai - bi : bi - ai;
return ci as u32;
}
在 Aleo 中,程序通过其程序名称来识别,而与其程序内容无关。程序通过名称导入另一个程序。这意味着开发人员在部署后应手动验证该程序。
例如,假设你要部署一个多重签名程序(比如 my_secure_multisig.aleo
)来持有你的资金。通常的步骤是首先部署 my_secure_multisig.aleo
程序,然后通过名称将资金发送到该程序。但是,攻击者可能能够抢在你的部署之前,并部署一个具有相同名称但内容完全不同的程序。然后,my_secure_multisig.aleo
程序将由攻击者控制。一旦你发送资金,你将立即失去所有资金。
另一个例子是关于程序导入。假设你有两个程序:programA.aleo
和 programB.aleo
。programA.aleo
导入 programB.aleo
。你需要首先部署 programB.aleo
,然后部署 programA.aleo
。攻击者有可能抢先部署一个假的 programB.aleo
在你的交易之前。然后,你的 programA.aleo
将导入假的 programB.aleo
。这将使整个程序不安全。
为了避免此类攻击,我们建议在你的部署交易后始终验证该程序是否由你部署。
作为一个新的区块链平台,Aleo 为构建私有应用程序提供了简单而强大的原语。尽管如此,这些新的原语可能会引入不直观的行为和漏洞,如果不小心。在本文中,我们讨论了 Aleo 程序的潜在漏洞。随着 Aleo 的发展,我们将密切关注其发展,并继续提供有关在此创新空间中维护安全性的见解。
- 原文链接: blog.zksecurity.xyz/post...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!