本文揭示了2024年11月在Aleo主网上发现的一个严重通胀漏洞的细节,该漏洞源于不安全的输入/输出提交方式,可能导致攻击者绕过最终确认逻辑,非法铸造代币。漏洞发现后,立即报告给Aleo团队并迅速修复,未发现实际利用。修复方案通过增加显式检查确保交易输入/输出数据类型的正确性。
2024 年 11 月,我们发现了 Aleo 主网中的一个通胀漏洞。我们立即向 Aleo 团队报告了这个漏洞。漏洞被识别并迅速修复。幸运的是,经过彻底扫描后,未检测到任何利用行为。
感谢 Aleo 团队的迅速和专业的行动。
在本文中,我将解释该漏洞的背景、它可能如何被利用以及如何修复它。
Aleo 是一个利用 ZKP(零知识证明)来实现隐私和可编程性的区块链网络。Aleo 使用从 Marlin 改编的 Varuna 证明系统。在 Aleo 上,用户生成交易执行的证明,验证者(网络)通过验证证明来验证交易的有效性。这种方法使网络高效,因为验证者不需要重新执行交易。此外,用户可以通过隐藏执行细节来保持他们的隐私。
Aleo 的交易由 transition 组成。一个 Transition 代表 Aleo 合约的一个函数的执行。Aleo 的合约语言是 Leo,一种直接的类 Rust 的 DSL(领域特定语言)。有关 Leo 的更多细节,请阅读 探索 Leo:Aleo 程序安全入门。下面是 Aleo 上的一个最小的可执行函数(一个 transition):
transition add_private_number(public a: u32, private b: u32) -> u32 {
let c: u32 = a + b;
return c;
}
在代码中,add_private_number
接受一个公共输入 a
和一个私有输入 b
,将两个数字相加并返回私有结果 c
(在 Aleo 上,输入和输出默认是私有的)。由于 b
和 c
是私有的,交易中的值被加密并且不会泄露给其他人。只有交易发送者可以解密加密的值。当用户发送一个调用该函数的交易时,网络确保 c
是 a
和 b
的和,而不知道 b
和 c
的值。
目前,一个 transition 可以处理像 bool
和 u64
这样的简单数据类型,这些类型是可克隆的,不适合表示资产(比如 token)。为此,Aleo 引入了 Record
模型。Record
是一种特殊的数据类型,它基本上是一个屏蔽池的叶子。它泛化了 Zcash 的设计,允许各种私有对象。
一个 transition
可以接受一个 Record
类型作为输入,并输出一个 Record
类型。不同之处在于,一个 Record
只能通过从一个 transition 返回来创建。每个 Record
都有一个所有者,并且只能由所有者花费。当一个 Record
作为 transition 的输入传递时,它会被消耗掉。当它被消耗时,会发出一个类似于 Zcash 的 nullifier,这确保了 Record
不能再次使用。换句话说,一个 Record
代表不可克隆的资产,这类似于 Move 语言 中的 Object
概念,但对其他人来说是私有的和不可见的。
一个程序可以定义自己的带有自定义字段的 Record
类型。这些字段默认也是私有的。只有创建者和所有者能够解密这些私有字段。下面是一个示例程序,用于 探索 Leo:Aleo 程序安全入门:
program example_program0.aleo {
record Token {
owner: address,
amount: u128
}
transition private_transfer_token(private receiver: address, private input_token: Token) -> Token {
let new_token: Token = Token {
owner: receiver,
amount: input_token.amount
};
return new_token;
}
}
上面的代码实现了私有 token 转移。它定义了一个名为 Token
的 record,带有 owner
和 amount
字段,这些字段默认是私有的。private_transfer_token
transition 接受 Token
record 作为输入,消耗它,并输出一个具有相同数量和新所有者的新 token。input_token
将被消耗,因为它被作为函数的输入传递。new_token
将被创建,因为它是由函数返回的。这确保了 token 的总供应量保持不变。由于 private_transfer_token
是链下执行的,并且输入/输出是私有的,网络不会知道发送者、接收者和数量。
Transition 对于链下计算非常有用。但是,它无法访问链上状态。例如,对于一个去中心化交易所(DEX)执行两个 token 之间的交换,它将需要获取和更新当前的链上价格。为此,在 Aleo 中,一个函数可以定义一个 finalize 逻辑,该逻辑在链上公开执行。
Finalize 逻辑可以读取和写入链上状态(例如,下面示例中的 data_map
)。当用户创建一个交易时,他们会在本地执行 transition 并生成证明,并且 transition 可以发出一个 Future
类型,该类型指定要在链上执行的逻辑。一旦交易被网络验证和接受,所有验证者将执行 Future
。这种方法还可以防止多个用户生成用于更新相同值的证明,这可能会导致 多用户应用程序的冲突更新问题。这是一个利用 finalize 功能的 Aleo 程序:
program example_program1.aleo {
mapping data_map: address => u64;
// transition is executed off-chain
async transition square_counter(public a: u64) -> Future {
let square: u64 = a * a;
return finalize_square_counter(self.caller, square);
}
// finalize is executed on-chain
async function finalize_square_counter(caller: address, square: u64) {
let v: u64 = data_map.get_or_use(caller, 0u64);
data_map.set(caller, square + v);
}
}
square_counter
transition 接受一个输入 a
并计算它的平方。然后它输出 Future
类型,其中包含函数(finalize_square_counter
)和参数(self.caller
和 square
)。这里的 Future
代表在证明时“延迟”的链上执行逻辑,类似于 Rust 或 JavaScript 中的 async task。在这个例子中,链上执行逻辑是由合约定义的 finalize_square_counter
函数。由于该函数将以类似于以太坊的方式被所有验证者公开执行,因此它可以读取和写入链上 data_map
映射。给定 caller
和 square
,该函数检索 caller
的 slot 中的先前值,添加 square
并写回映射。
在 Leo playground 中使用 leo run square_counter 1u64
运行上面的 square_counter
transition,你可以得到结果:
你可以看到 transition 的输出正好是一个 Future
类型。该 future 包含程序 id、函数名和该函数的参数。如果 transition 有效,那么验证者将使用给定的参数执行 finalize 逻辑。
最后,我们可以对 Aleo 中使用所有这些功能的交易的完整生命周期有一个高层次的了解。在这里,用户想要生成一个智能合约方法的执行,该方法已经部署在 Aleo 上:
用户在本地执行 transition,生成 transition 的输入和输出。
用户为该 transition 生成一个执行证明(使用 ZKP),并将其包含在一个交易中,以及:
最后,用户将所有这些内容在一个交易中发送到网络。
验证者验证交易的证明。也就是说,给定函数和输入的承诺,输出的承诺是正确的。验证者还验证给定的输入/输出数据是否正确地对应于该承诺。
如果交易证明有效,则网络接受该交易。如果 transition 输出一个 Future
,那么执行相应的链上 finalize 逻辑。
现在你已经了解了足够多的知识来理解我们发现的漏洞。让我们开始吧!
作为我们与 Aleo 持续安全流程的一部分,我们设法发现了一个重要的漏洞。该漏洞是由一种不安全的承诺输入/输出的方式引起的,允许攻击者绕过 finalization 逻辑。
在 Aleo 中,一个 transition 由验证者分两步检查:
下面是检查输出数据的第二步的代码片段。给定输出,verify
函数检查 Output
是否正确地对应于输出承诺/哈希。这是每个验证者为每个交易执行的链下代码。你能找到漏洞是在哪里引入的吗?
/// The transition output.
#[derive(Clone, PartialEq, Eq)]
pub enum Output<N: Network> {
[...]
/// The ciphertext hash and (optional) ciphertext.
Private(Field<N>, Option<Ciphertext<N>>),
/// The output commitment of the external record. Note: This is **not** the record commitment.
ExternalRecord(Field<N>),
/// The future hash and (optional) future.
Future(Field<N>, Option<Future<N>>),
}
impl<N: Network> Output<N> {
pub fn verify(&self, function_id: Field<N>, tcm: &Field<N>, index: usize) -> bool {
// Ensure the hash of the value (if the value exists) is correct.
let result = || match self {
[...]
Output::Private(hash, Some(value)) => {
match value.to_fields() {
// Ensure the hash matches.
Ok(fields) => match N::hash_psd8(&fields) {
Ok(candidate_hash) => Ok(hash == &candidate_hash),
Err(error) => Err(error),
},
Err(error) => Err(error),
}
}
Output::Future(hash, Some(output)) => {
match output.to_fields() {
Ok(fields) => {
// Construct the (future) output index as a field element.
let index = Field::from_u16(index as u16);
// Construct the preimage as `(function ID || output || tcm || index)`.
let mut preimage = Vec::new();
preimage.push(function_id);
preimage.extend(fields);
preimage.push(*tcm);
preimage.push(index);
// Ensure the hash matches.
match N::hash_psd8(&preimage) {
Ok(candidate_hash) => Ok(hash == &candidate_hash),
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
[...]
Output::ExternalRecord(_) => Ok(true),
};
[...]
}
}
该漏洞源于不同输出类型的哈希/承诺模式。Output
的承诺模式仅包括数据,而不吸收它的子类型。这意味着我们可以创建两个具有相同承诺的不同子类型的 Output
。例如,Output::Future(hash, Some(output))
通过将数据连接到 preimage
并获取它的哈希来检查。Output::Private(hash, Some(value))
通过直接哈希 value
来检查。然后我们可以通过简单地将 Output::Private
中的 value
设置为 Output::Future
的 preimage
来创建具有完全相同哈希的 Output::Private
。
这意味着承诺模式不具有约束力。不幸的是,Aleo 仅依赖于承诺检查来进行输入/输出验证,而没有其他地方的显式类型检查。因此,攻击者可以用交易中不同类型的数据替换输出,并且仍然可以通过检查。这种设计缺陷引入了一个关键漏洞。
更进一步,请记住 Future
数据表示要在链上执行的 finalized 逻辑。如果 Future
数据被另一种类型的数据(例如,Output::Private
)替换,那么 finalize 逻辑将不会在链上执行。通过这种方式,攻击者可以绕过其交易中的任何 finalize 逻辑。其中一个影响是攻击者可以发行任意数量的 Aleo token。
考虑以下存在于 Aleo 的 token 合约中的函数:
program example_program2.aleo {
record Token {
owner: address,
amount: u64
}
mapping public_balance: address => u64;
async transition transfer_public_to_private(recipient: address, amount: u64) -> (Token, Future) {
let new_token: Token = Token {
owner: recipient,
amount: amount
};
let f: Future = finalize_transfer(self.caller, amount);
return (new_token, f);
}
async function finalize_transfer(caller: address, amount: u64) {
let balance: u64 = public_balance.get_or_use(caller, 0u64);
let new_balance: u64 = balance - amount; // transaction will revert if underflow happens
public_balance.set(caller, new_balance);
}
}
transfer_public_to_private
将公共 token(存储在链上的 public_balance
映射中)转移到私有 token(由 record 存储)。它在一个交易中发行新的 token record 并减去相同数量的余额。如果公共余额不足,交易将回滚。但是,如果跳过 finalize_transfer
逻辑,则整个交易将部分执行。新的 token record 仍然会由 transition 创建,但链上余额不会被减去。通过这种方式,攻击者可以发行任意数量的 token。
以下是可以执行攻击的方式:
recipient
作为自己的地址和大的 amount
执行 transfer_public_to_private
transition。然后攻击者像往常一样生成证明和交易。Output::Token
类型和一个 Output::Future
类型。攻击者将 Output::Future
替换为具有相同承诺值的 Output::Private
类型的数据。攻击者将交易发送到网络。Token
record。它发现输出中没有 Future
类型(已被 Output::Private
类型替换),然后不会执行链上 finalize 逻辑。Token
被创建,但链上公共余额未被减去。通过这种方式,攻击者免费创建了一个新的 Token
。请注意,如果发生此类攻击,我们可以通过检查输入/输出数据不匹配来找到它。
在发现该问题后,我们立即将其报告给 Aleo 团队。Aleo 团队确认了该问题,并扫描了所有现有交易以查找利用的迹象。幸运的是,没有发现任何利用。然后提出了 修复 并合并。由于承诺模式涉及协议的多个部分(包括电路),因此修改起来具有挑战性。因此,通过添加显式检查以确保 transition 的输入/输出数据类型是正确的来修复它。
pub fn verify_execution(&self, execution: &Execution<N>) -> Result<()> {
[...]
// Ensure the input and output types are equivalent to the ones defined in the function.
// We only need to check that the variant type matches because we already check the hashes in
// the `Input::verify` and `Output::verify` functions.
let transition_input_variants = transition.inputs().iter().map(Input::variant).collect::<Vec<_>>();
let transition_output_variants = transition.outputs().iter().map(Output::variant).collect::<Vec<_>>();
ensure!(function.input_variants() == transition_input_variants, "The input variants do not match");
ensure!(function.output_variants() == transition_output_variants, "The output variants do not match");
[...]
}
后来,该修复程序被部署到所有验证者。添加了更多测试,以确保检测到格式错误的输入和输出。
我们发现了 Aleo 中的一个关键漏洞,该漏洞可能允许任意 token 铸造。我们立即将该问题报告给 Aleo 团队,并共同努力修复了该问题。
从这次经历中获得的主要经验是,在承诺和哈希数据时,遵循 “TLV” 模式(类型、长度、数据)的重要性。这有助于确保承诺是安全的并防止利用。
安全是一个持续和发展的过程。对于其安全实践不透明的零知识项目更容易出现漏洞。通过不断提高标准并严格审查系统,我们可以及早发现和缓解关键问题。随着 zk 生态系统的发展,我们仍然致力于保护 zk 项目并为其长期可靠性和安全性做出贡献。
- 原文链接: blog.zksecurity.xyz/post...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!