NEAR智能合约审计:账户与访问控制

本文深入探讨了NEAR账户系统的工作原理、安全性考量以及这些设计选择对开发者和用户的实际影响。NEAR账户系统在安全性、可用性和灵活性之间实现了平衡,具有用户友好的命名账户、分层结构和细粒度的权限系统。文章还讨论了在NEAR上构建应用时需要注意的安全问题和最佳实践。

这是分为三部分的系列文章的第三部分。欢迎查看其他部分:

介绍

在本文中,我们将从根本上研究 NEAR 账户的工作方式,探讨它们提出的安全考虑因素,并分析这些设计选择对开发者和用户的实际影响。通过理解这些底层机制,我们可以更好地理解所涉及的权衡,并在 NEAR 网络上实现更强大的系统。

区块链账户的人性面孔

如果你与 NEAR 互动过,你可能已经注意到账户看起来更熟悉:alice.near 而不是 0x7c5206b1b75b8787420b09d8697e08180cdf896c。这种人类可读的方法代表了 NEAR 的设计理念,该理念贯穿整个协议。

命名账户 vs 隐式账户

NEAR 账户的设计旨在用户友好和易于访问,但同时也安全和灵活。为了实现这种平衡,他们的账户系统有两种不同的风格。

命名账户

alice.nearmycontract.alice.near 这样的账户是分层的、可读的和容易记住的。它们遵循简单的规则:

  • 长度为 2-64 个字符
  • 仅限小写字母数字字符
  • 句点(.) 分隔域名和子账户
  • 最右边的部分通常表示顶级域名(near 或 testnet)

隐式账户

这些在区块链世界中更为传统——它们直接从加密公钥派生而来,从而产生那些熟悉的 64 个字符的十六进制字符串。虽然对人类不太友好,但它们在生态系统中发挥着重要作用。

这种双重方法提供了灵活性:用户可以拥有友好的、容易记住的账户以用于日常使用,而开发者可以在需要时利用隐式账户的数学属性。

访问管理

从安全的角度来看,NEAR 账户系统特别有趣的地方在于其访问密钥的方法。NEAR 没有采用简单的“谁拥有私钥谁就控制账户”的模型,而是实现了一个更细致的系统。

每个账户可以有多个关联的密钥,每个密钥具有不同的权限级别。

完全访问密钥

这些是你王国的密钥——它们允许对账户执行任何操作,包括部署合约、转移代币,甚至完全删除账户。权力越大,责任越大,这些密钥应妥善保管。

函数调用密钥

函数调用密钥可以通过多种方式进行限制:

  • 它们只能调用特定的智能合约方法
  • 它们可以限制为特定的智能合约
  • 它们可以有有限的 gas 允许量
  • 它们不能直接转移 NEAR 代币

这种细粒度的权限系统允许独特的安全模式。例如,你可以创建一个函数调用密钥,允许移动应用程序代表你进行特定交易,而不会在密钥泄露时危及你的整个账户。

信任的层次结构

NEAR 的账户系统实现了一个分层结构,该结构反映了互联网的域名系统。一个账户可以在其下创建子账户,但不能在其自己的级别创建账户。

例如,alice.near 可以创建 myproject.alice.near,但不能创建 bob.near。这种分层方法:

  1. 创建清晰的所有权边界
  2. 防止命名空间冲突
  3. 允许特定于组织的子账户结构
  4. 启用委托账户管理

这种模式感觉很直观,因为我们已经熟悉电子邮件地址和网站中的类似层次结构,但它对应用程序和组织如何构建其链上呈现具有重要意义。

安全影响和最佳实践

NEAR 的灵活账户和权限模型创造了强大的功能,但也引入了开发者必须仔细解决的特定安全考虑因素。我们将检查在 NEAR 上构建时要考虑的最重要的安全方面。

访问控制基础知识

NEAR 合约中的安全基础始于正确的访问控制。在构建智能合约时,必须准确了解哪些功能应该可以被哪些账户访问。

正确的环境变量使用

NEAR 提供了几个有助于建立身份的环境变量:

  • current_account_id() - 引用合约本身(类似于 Solidity 中的 address(this)
  • predecessor_account_id() - 标识直接调用者(类似于 Solidity 中的 msg.sender
  • signer_account_id() - 表示签署原始交易的账户(类似于 Solidity 中的 tx.origin

关键的安全原则是依赖 predecessor_account_id() 进行访问控制,而不是 signer_account_id()。使用 signer_account_id() 是有风险的,因为它使合约容易受到网络钓鱼攻击。恶意合约可以诱骗用户签署交易,然后使用跨合约调用来访问其他合约中的受保护功能,同时保留原始签名者的身份。

// 不安全:容易受到通过跨合约调用进行的网络钓鱼攻击
pub fn transfer_funds(&mut self, to: AccountId, amount: U128) {
    if env::signer_account_id() == self.owner_id {
        // 此检查可以通过一系列合约绕过
        // 转移资金...
    }
}

// 安全:正确的访问控制
pub fn transfer_funds(&mut self, to: AccountId, amount: U128) {
    if env::predecessor_account_id() == self.owner_id {
        // 此检查确保直接调用者是所有者
        // 转移资金...
    }
}

缺少访问控制

NEAR 合约中最常见的安全问题之一是公开管理级别或内部功能,而没有适当的访问控制。例如,合约可能包含一个暂停所有操作的函数,但没有限制谁可以调用它。

// 脆弱:关键功能没有访问控制
pub fn pause(&mut self) {
    self.status = ContractStatus::Paused;
}

// 安全:具有访问控制
pub fn pause(&mut self) {
    assert!(
        env::predecessor_account_id() == self.owner,
        "Not allowed"
    );
    self.status = ContractStatus::Paused;
}

如果没有这些检查,任何人都可以暂停合约,从而导致拒绝服务情况或其他意外行为。

保护回调函数

NEAR 中的跨合约调用是异步的,从而在初始调用及其回调执行之间产生潜在的漏洞。正如在我们之前的文章中更详细解释的那样,必须保护回调以防止未经授权的访问。

#[private] 宏对于回调保护至关重要:

// 脆弱:未受保护的回调
pub fn ft_resolve_transfer(
    &mut self,
    sender_id: AccountId,
    receiver_id: AccountId,
    amount: U128,
) -> U128 {
    // 恶意行为者可以直接使用
    // 任意参数调用此函数以耗尽资金
    // 处理转移结果...
}

// 安全:使用 #[private] 宏保护的回调
##[private]
pub fn ft_resolve_transfer(
    &mut self,
    sender_id: AccountId,
    receiver_id: AccountId,
    amount: U128,
) -> U128 {
    // 只有合约本身才能调用此函数
    // 处理转移结果...
}

#[private] 宏扩展为一项检查,以确保 current_account_id() == predecessor_account_id(),旨在保证只有合约本身才能调用该函数。

重要的边缘情况:账户密钥绕过

使用 #[private] 宏时,需要注意一个微妙但重要的边缘情况。如果合约帐户(例如 alice.near)具有可以调用特定功能的访问密钥(完全访问权限或函数调用密钥),则帐户持有人可能会绕过 #[private] 保护。

这之所以可行,是因为:

  • 当帐户使用其密钥直接调用其自己的合约的功能时,current_account_id()predecessor_account_id() 都将返回相同的值 (alice.near)。
  • 即使这是外部调用,#[private] 检查也会通过。

宏使用和 trait 实现风险

NEAR 的 Rust SDK 使用 #[near] 之类的宏来生成样板代码。但是,不正确地使用这些宏可能会导致安全漏洞。

一个特别微妙的问题涉及 trait 实现。使用 #[near] 标记 trait 实现时,该 trait 中的所有方法都会暴露以供外部调用,即使某些方法仅用于内部使用也是如此:

pub trait Pausable {
    fn toggle_pause(&mut self);
    fn pause(&mut self);
    fn unpause(&mut self);
    fn when_not_paused(&self);
}

// 脆弱:将所有 trait 方法暴露给外部调用
##[near]
impl Pausable for StatusMessageContract {
    fn toggle_pause(&mut self) {
        // 即使应该受到限制,任何人都可以调用此函数
        // ...
    }
    // ...
}

// 安全:使用没有 #[near] 的内部 trait 或使用显式访问控制
##[near]
impl StatusMessageContract {
    pub fn pub_toggle_pause(&mut self) {
        assert!(env::predecessor_account_id() == self.owner, "Permission Denied");
        self.toggle_pause()
    }

    fn toggle_pause(&mut self) {
        // 无法直接调用的内部实现
        // ...
    }
}

实现组织的最佳实践

  1. 对所有可公开调用的函数使用单个 #[near] 注释的 impl
  2. 对内部函数和 trait 实现使用单独的未注释的 impl
  3. 在私有函数中实现敏感 trait,然后使用包含访问控制的公共函数来包装它们
// 良好的模式:将 trait 实现与合约接口分开
// 没有 #[near] 的 trait 实现 - 不可直接调用
impl Pausable for StatusMessageContract {
    fn toggle_pause(&mut self) {
        // 内部实现
        self.paused = !self.paused;
    }
    // 其他 trait 方法...
}

// 带有 #[near] 的公共接口 - 包含访问控制
##[near]
impl StatusMessageContract {
    pub fn admin_toggle_pause(&mut self) {
        assert!(env::predecessor_account_id() == self.owner, "Permission Denied");
        // 调用 trait 实现
        self.toggle_pause();
    }

    // 其他公共合约方法...
}

你可以使用 cargo-expand 检查宏如何扩展,并确保它们不会暴露意外的功能。

请注意,#[near] 替换了已弃用的宏 #[near_bindgen],但存在一些细微的差异。有关确切的更改,请查看文档

One-Yocto 模式

NEAR 生态系统中的一种优雅的安全模式是“assert_one_yocto()”方法。由于函数调用密钥无法将 NEAR 代币附加到交易,因此即使需要附加一个小的代币(1 yocto = 10^-24 NEAR)也可以有效地确保该操作必须获得完全访问密钥或其他合约的授权。

pub fn withdraw_funds(&mut self) {
    // 需要附加 1 yocto 以确保使用完全访问密钥调用此函数
    assert_one_yocto();
    // 执行敏感操作...
}

这在应该需要完全授权的操作和可以委托给函数调用密钥的操作之间创建了清晰的区别。

基于角色的访问控制 (RBAC)

依赖单个密钥进行所有特权操作会产生中心化风险。如果该密钥泄露,整个系统都会受到攻击。基于角色的访问控制根据其角色在不同帐户之间划分权限。

// 风险:单所有者模型
pub fn blacklist_account(&mut self, account: AccountId) -> String {
    self.assert_owner();
    format!("Account Blacklisted: {account}")
}

// 更安全:基于角色的访问控制
pub fn blacklist_account(&mut self, account: AccountId) -> String {
    self.assert_security_role();
    format!("Account Blacklisted: {account}")
}

pub fn set_price(&mut self, price: u128) -> String {
    self.assert_oracle_role();
    format!("Price set: {price}")
}

pub fn remove_pool(&mut self, pool_id: u128) -> String {
    self.assert_manager_role();
    format!("Pool removed: {pool_id}")
}

对于关键应用程序,请考虑为敏感操作实施多重签名要求,或过渡到 DAO 治理以实现最重要的功能。

可以在 NRML (NEAR Rust Macros Library)NEAR Contract Tools 等库中找到特定于 NEAR 的 RBAC 实现。

完全访问密钥的中心化风险

由完全访问密钥控制的智能合约代表着中心化风险。如果该密钥泄露,则可以替换或耗尽整个合约,因为 NEAR 允许在部署后更新合约代码(与以太坊不同)。 对于真正去中心化的应用程序,请考虑以下方法:

  • 部署后删除所有密钥(进入“无访问密钥”状态)
  • 对敏感操作实施时间锁
  • 需要对代码更新进行多重签名批准
  • 过渡到 DAO 治理

仍然可以与没有访问密钥的合约进行交互,但没有任何单个实体可以修改其代码或将其删除。

最简单和最可靠的方法是在合约部署后在交易中使用 DeleteKey 操作

// 删除指定的密钥
await account.deleteKey(publicKeyToRemove);

结论

NEAR 的账户系统代表了安全性、可用性和灵活性之间的周全平衡。通过实施人类可读的账户、分层结构和细粒度的权限系统,NEAR 创建了一个既对开发人员强大又对用户可访问的基础架构。

在 Sigma Prime,我们致力于保护和加强各种区块链网络和协议。如果你正在构建解决方案并希望利用我们在该领域的前沿安全专业知识,请联系我们

  • 原文链接: blog.sigmaprime.io/near-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
sigmaprime
sigmaprime
江湖只有他的大名,没有他的介绍。