NEAR智能合约审计:存储

本文深入探讨了 NEAR 区块链的存储系统,包括存储的工作原理、安全使用方法以及常见的陷阱。重点介绍了NEAR的存储机制(Storage Staking),以及如何使用 NEAR SDK 进行存储,需要注意 collection 的前缀需要保证唯一性,避免出现覆盖问题。

介绍

在本文中,我们将深入研究 NEAR 区块链的存储系统。我们将探讨存储在 NEAR 上是如何工作的,如何安全地使用它,并重点介绍一些常见的陷阱。

这是我们关于 NEAR 区块链的第二篇文章。如果你错过了第一篇文章,它深入探讨了分片和跨合约调用。

NEAR 存储如何工作

NEAR 上的每个帐户都有存储空间,可用于以持久的方式存储数据。因此,它通常用于存储诸如余额、NFT 元数据或其他变量。在底层,NEAR 上的存储是一个键值存储。但是,开发人员很少直接与 kv-store 交互,而是使用 NEAR SDK 来利用存储。

NEAR SDK 抽象了 kv-store,并提供了对存储更友好的交互。SDK 允许开发人员定义存储布局。这是一个用 #[near(contract_state)] 宏标记的结构体,它划定了此合约中哪些变量在存储中。一个合约只能定义一个存储布局结构体。例如,下面的合约在其存储中有 2 个变量:一个字符串和一个字节向量。

#[near(contract_state)]
pub struct Contract {
    greeting: String,
    vector: Vector<u8>,
}

可以使用所有常规的 Rust 数据类型,如 i32String,但 NEAR SDK 提供了集合类型的替代品(稍后会详细介绍)。default() 函数可用于为状态变量分配默认值。 当实例化 SDK 集合类型(如 Vector)时,你必须为其指定唯一的 前缀。此前缀用于计算此集合的存储位置。

fn default() -> Self {
    Self {
        greeting: "hi".to_string(),
        vector: Vector::new("vecPref"),
    }
}

SDK 将完成序列化和反序列化这些高级变量与底层 kv-store 之间的繁重工作。执行此操作时,SDK 对 kv-store 使用以下结构:

  • 主合约状态被序列化为字节(使用 Borsh)并存储在键为 STATE 的 kv-store 中。
  • 集合类型(如 Vector)使用用户定义的前缀单独存储。集合的每个元素都存储在自己的键值对中,键为 prefix|index of element

在我们的示例中,假设 greeting = 'hi' 并且 vector 有一个等于 1 的元素,则合约将在其 kv-store 中包含以下内容:

[\
    {\
        "key": "STATE",\
        // u32(greeting.length)|greeting|u64(vector.length)|u32(vector.prefix.length))|vector.prefix\
        "value": "\x02\x00\x00\x00hi\x01\x00\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00vecPref"\
    },\
    {\
        // vector.prefix|u64(index to access)\
        "key": "vecPref\x00\x00\x00\x00\x00\x00\x00\x00",\
        // vector[0]\
        "value": "\x01"\
    }\
]
// Modified from https://docs.near.org/build/smart-contracts/anatomy/serialization#modifying-the-state

当调用将 self 作为参数的函数时,主合约状态(键为 STATE)将被反序列化并完整地存储在内存中。这与 SDK 集合类型的内容形成对比,它们仅在需要时(例如,在 vec.get() 操作期间)反序列化并加载到内存中。

同样,主合约状态也会从内存中序列化,并在 合约执行结束时 存储回 kv-store 中。相反,集合类型的内容会在 集合被删除 (超出范围)时从内存中序列化并存储在存储中。这种细微的差异有一些含义,将在后面解释。

SDK 类型

现在很清楚存储在较低级别上是如何工作的,我们可以解释 SDK 集合类型(Vector)和本机 Rust 集合类型(Vec)之间的重要区别:SDK 集合如上所述存储在单独的键值对中,相反,本机 Rust 集合将存储在主合约状态(STATE)中。由于主合约状态始终完整加载,这意味着 Rust 集合也将始终作为一个整体加载。对于大型集合来说,这可能非常消耗资源,甚至可能导致 DoS 问题。因此,对于大多数集合应使用 SDK 类型,而本机 Rust 集合仅应用于非常小的集合。

另一种应该使用 SDK 类型而不是本机类型的情况是 u64u128 类型。由于 JSON 用于编码函数调用的输入和输出,而 JSON 无法直接表示 u64u128 大小的整数,因此应使用 SDK 类型 U64U128 而不是本机 Rust 类型 u64u128。SDK 类型在必要时会自动表示为字符串。

升级合约

NEAR 具有用于升级合约的内置功能。这允许在部署后修改合约的代码,但是,在此过程中不会修改存储。这意味着存储布局必须保持不变。否则,如果存储布局与存储不匹配,则可能会发生反序列化错误,并且合约将不再起作用。或者,如果必须更改存储布局,则可以实现一个迁移函数,该函数将存储迁移到新的存储布局。

存储质押

NEAR 实现了存储质押。这意味着根据合约中使用的存储量锁定一定数量的 NEAR。释放存储时,NEAR 从合约中解锁,可以转出。这意味着合约应始终拥有足够的 NEAR 代币来支付其可能执行的任何存储分配,否则在尝试写入时会发生 panic。存储成本应转嫁给用户,否则,合约会让自己面临恶意破坏和 DoS 攻击(将在下一节中介绍)。

NEP-145 定义了存储管理标准,这是一个将存储成本转嫁给用户的通用接口。使用此标准时,用户必须注册到合约并存入一些 NEAR 以支付其存储费用。他们可以根据自己对合约的使用情况来增加或提取其存储余额。

安全问题

存储质押安全

存储质押机制意味着 NEAR 会根据合约使用的存储量被锁定。因此,合约必须准确跟踪每个用户消耗的存储量,并将此成本转嫁给用户。如果合约不这样做,它将打开几个攻击向量:

  • 如果合约没有足够的 NEAR 来支付其存储费用,它将 panic,导致 DoS 问题。
    • 如果此 panic 在执行的关键部分(如回调)期间发生,则可能会导致资金损失。
  • 如果合约仅将部分存储成本转嫁给用户,则用户可以发起恶意破坏攻击。通过使用大量存储,用户迫使合约支付大量 NEAR,而自己仅支付一小部分。这最终可能导致上述拒绝服务情况。
    • 此外,如果合约需要一些 NEAR 来支付其他费用(例如,向用户支付奖励),则此余额可能会被存储质押锁定,这意味着合约不再有足够的余额来支付奖励。

前缀冲突

使用 SDK 集合类型 时,开发人员必须指定前缀。此前缀用于计算集合在键值存储中存储位置的键。

  • 因此,至关重要的是此 前缀是唯一的。如果前缀不是唯一的,那么这些集合将从同一存储中读取/写入,并且会相互覆盖。
  • 这里的最佳实践是使用 enum 来定义存储键。这保证了它们的唯一性。例如:
#[near]
#[derive(BorshStorageKey)]
pub enum Prefix {
    Owners,
    Users,
}

#[near(contract_state)]
pub struct StorageExample {
    pub owners: Vector<String>,
    pub users: LookupSet<String>,
}

impl Default for StorageExample {
    fn default() -> Self {
        Self {
            owners: Vector::new(Prefix::Owners),
            users: LookupSet::new(Prefix::Users),
        }
    }
}

// Modified from https://docs.near.org/build/smart-contracts/anatomy/collections#sdk-collections

不安全的集合

如上所述,在修改变量时,一些状态(例如 Vector 的内容)可以在保存其他状态(例如 Vector 的长度) 之前 保存到存储中。这可能会在某些情况下导致一些意外行为: 当实例化 2 个 具有相同前缀 的对象时,它们可以具有相同的内容(因为该内容已保存到存储中),但长度不同(保存在内存中)

let mut m = UnorderedMap::<u8, String>::new(b"m");
m.insert(1, "test".to_string()); // this writes "test" to storage
assert_eq!(m.len(), 1); // length is saved in memory
assert_eq!(m.get(&1), Some(&"test".to_string()));

m = UnorderedMap::new(b"m"); // instantiate second object with same prefix
assert!(m.is_empty());                            // not same length
assert_eq!(m.get(&1), Some(&"test".to_string())); // but same contents

/// From https://docs.near.org/build/smart-contracts/anatomy/collections#error-prone-patterns

集合应附加到主状态。否则,内容将被写入存储,但元数据(如长度)会丢失。以下示例演示了这一点:

#[near(contract_state)]
pub struct Contract {
    vector: Vector<i32>,
}

impl Default for Contract {
    fn default() -> Self {
        Self {
            vector: Vector::new("pref".as_bytes()),
        }
    }
}

#[near]
impl Contract {
    pub fn test(&mut self) -> String {
        {
            // v is 'detached from the state'
            let mut v = Vector::<u32>::new("pref".as_bytes());
            v.push(1);
            assert_eq!(v.len(), 1);
            assert_eq!(v.get(0).unwrap(), &1);
        } // when v is dropped its contents are saved, but its metadata (length) is lost

        assert_eq!(self.vector.len(), 0); // length was not saved

        // when reading the storage manually, the content is intact
        let storage_key = &[b"pref".as_slice(), &0u32.to_le_bytes()].concat();
        assert_eq!(
            near_sdk::env::storage_read(storage_key).unwrap(), 1u32.to_le_bytes()
        );
    }
}

NEAR SDK 的 collections vs store

这篇文章假设使用 near_sdk::store 来进行集合操作。这是旧的 near_sdk::collections 模块的新版本。store 具有更好的 gas 效率以及其他优势。此外,collections 具有一些在嵌套时可能导致问题的行为。因此,建议使用 store 而不是 collections

结论

NEAR 中的帐户存储具有一些有趣的机制,例如存储质押和集合管理。重要的是要了解这些机制及其细微之处是如何工作的,以便创建安全且强大的智能合约。

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

0 条评论

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