其他
Motoko 中容器智能合约开发的良好实践
我在这里分享该列表以及一些一般性建议,希望它对更广泛的社区有所帮助。
容器间调用
互联网计算机系统提供遵循参与者模型的容器间通信:容器间调用通过两条异步消息实现,一条用于发起调用,一条用于返回响应。容器以原子方式处理消息(并在某些错误条件下回滚),但不完成调用,这使得使用容器间调用进行编程很容易出错。
错误、漏洞或只是意外行为的可能常见来源是:
在发出容器间调用之前读取全局状态,并假设它在调用返回时仍然保持; 在发出容器间调用之前更改全局状态,在响应处理程序中再次更改它,但假设没有其他任何更改之间的状态(重入); 在发出容器间调用之前更改全局状态,并且未正确处理故障,例如,当处理回调的代码回滚时。
如果您在代码中发现这种模式,您应该分析恶意方是否可以触发它们,并评估影响的严重性,这些问题适用于所有容器,并非 Motoko 特有的。
回滚
即使没有容器间调用,回滚的行为也可能令人惊讶。特别是,拒绝(即,throw)不会回滚之前完成的状态更改,而捕获(例如 Debug.trap,assert …,超出 cycle 条件)会。
因此,应该检查所有公共更新调用入口点是否存在不需要的状态更改或不需要的回滚,特别是,查找状态更改后跟 throw。
此问题适用于所有容器,并且不是 Motoko 特定的,尽管其他 CDK 可能不会将异常变为拒绝(不会回滚)。
与恶意容器交谈
由于以下(可能不完整)原因,与不可靠的容器交谈可能会有风险:
另一个容器可以拒绝响应,尽管互联网计算机的双向消息传递范式旨在保证最终响应,但只要对方愿意支付费用,对方就可以在响应之前进行忙循环,更糟糕的是,有一些方法可以使容器陷入僵局。 另一个容器可以用无效编码的 Candid 响应,这将导致 Motoko 实现的容器陷入回复处理程序中,没有简单的方法可以恢复,其他 CDK 可能会为您提供更好的方法来处理无效的 Candid,但即便如此,您也必须担心会导致您的回复处理程序陷入陷阱的 Candid cycle 炸弹。
许多容器甚至不进行容器间调用,或者只调用其他可信赖的容器,对于其他人,需要仔细评估其影响。
容器升级:概述
对于大多数服务而言,能够可靠地升级容器至关重要,这可以分解为以下几个方面:
容器可以升级吗? 容器升级会保留所有数据吗? 容器能及时升级吗? 无法升级时是否有恢复计划?
容器可升级性
无论出于何种原因,在其 canister_preupgrade 系统方法中捕获的容器不再可升级,这是一个重大风险。Motoko 容器的 canister_preupgrade 方法包括开发人员编写的任何 system func preupgrade() 块中的代码,然后是系统生成的代码,该代码将任何块的内容序列 stable var 化为二进制格式,然后将其复制到稳定的内存中。
由于 Motoko 内部序列化代码将首先序列化到主堆中的暂存空间,然后将其复制到稳定内存,因此具有超过 2GB 实时数据的容器可能无法升级,但这不太可能是第一个限制。
系统对升级容器(同时跨越 canister_preupgrade 和 canister_postupgrade)施加指令限制,此限制是子网配置值,与正常的每条消息限制分开(并且可能更高),并且不容易确定。如果容器的实时数据变得太大而无法在此限制内序列化,则容器将变为不可升级。
只要使用 Motoko 和 Stable Variables,这种风险就无法完全消除,它可以通过适当的负载测试来缓解。
安装一个容器,用实时数据填充它,然后进行升级,如果实时数据集成功地超出预期的数据量,那么这种风险可能是可以接受的,添加阻止容器的实时数据超过一定大小的功能的奖励积分。
如果要在本地副本上进行此测试,则需要格外小心以确保本地副本实际执行指令计数并具有与生产子网相同的资源限制。
另一种缓解措施是尽可能避免 canister_pre_upgrade,这意味着不使用 stable var(或仅限于小的、固定大小的配置数据),所有其他数据可能是:
从容器镜像(可能链下)并在升级后手动重新组合; 在每次更新调用期间,使用 ExperimentalStableMemory API 手动存储在稳定内存中,虽然这与高保证 Rust 容器(例如互联网身份)所做的相匹配,但这需要对数据进行手动二进制编码,并且标记为实验性的,因此我目前不推荐这样做。 在 Motoko 为稳定变量提供可扩展的解决方案之前,不要放入 Motoko 容器中,例如,将它们永久保存在稳定的内存中,在主内存中进行智能缓存,从而消除对预升级代码的需求。
升级时的数据保留
显然,在升级期间应该保留所有实时数据,Motoko 自动为 stable var 数据确保这一点。但通常容器希望以不同的格式处理他们的数据(例如,在不是 shared 并且因此不能放入 stable vars 的对象中,例如 HashMap 或者 Buffer 对象,因此可能遵循以下习惯用法:
在这种情况下,重要的是检查:
所有不稳定的全局 vars 或具有可变值的全局 lets,都有一个稳定的伙伴; 分配给 foo 和 fooStable 不会被遗忘; fooToStable 和 fooFromStable 形成双射。
一个示例通过 Iter.toArray(….entries())HashMap.fromIter(….vals()) 将 HashMaps 存储为数组。
值得指出的是,代码视图只会查看单个版本的代码,但无法检查代码更改是否会在升级时保留数据。如果以不兼容的方式更改稳定变量的名称和类型,这很容易出错。
在这种情况下,升级可能会严重失败,但在糟糕的情况下,升级甚至可能会成功,并在此过程中丢失数据,这种风险需要通过彻底的测试和可能的备份来减轻(见下文)。
及时升级
当 Motoko 和 Rust 容器仍在等待容器间调用的响应时,它们无法安全升级(回调最终会到达新实例,并且由于 IC 的系统 API 的不合理性,可能会调用任意内部函数)。
因此,升级前需要停止容器,然后重新启动。如果容器间调用需要很长时间,这意味着升级可能需要很长时间,这可能是不可取的。同样,如果所有调用都对可信赖的容器进行,这种风险就会降低,而当直接或间接调用可能不可靠的容器时,这种风险就会升高。
备份和恢复
由于上述升级风险,建议制定灾难恢复策略,这可能涉及所有相关数据的链下备份,以便可以 reinstall(不是 upgrade)存储容器并重新上传所有数据。
请注意,与上面 upgrade “提示升级”中描述 reinstall 的问题相同,应该首先停止它以确保安全。
请注意,消息的指令限制以及消息大小限制会限制返回的数据量,如果容器需要保存更多的数据,则备份查询方法可能必须返回块或增量,这会带来所有额外的复杂性,例如下载块之间的状态变化。
如果执行大数据负载测试(无论如何我建议测试可升级性),可以测试备份查询方法是否在资源限制内工作。
时间不是严格单调的
互联网计算机提供给其容器的“当前时间”时间戳保证是单调的,但不是严格单调的。它可以返回相同的值,即使在相同的消息中,只要它们在同一个块中处理。因此,它不应该用于检测“之前发生”的关系。
与其使用和比较时间戳来检查在 X 上次发生后是否执行了 Y,不如引入一个显式 var y_done : Bool 状态,该状态 False 由 X 设置,然后 True 由 Y 设置。当事情变得更复杂时,对该状态进行建模会更容易通过带有说话标签名称的枚举,并在此过程中更新此“状态机”。
这个问题的另一个解决方案是引入一个 var v : Nat 计数器,您可以在每个更新方法中以及每个 await 之后进行,现在 v 是您的容器的状态计数器,可以在许多方面像时间戳一样使用。
当我们谈论时间时,系统时间(通常)通过 await 改变,因此,如果您这样做 let now = Time.now() 然后 await,则 now 里面的值可能不再是您想要的。
包装算法
Nat64 数据类型和其他固定宽度的数字类型提供可选的包装算法(例如,+%,fromIntWrap),除非当前应用程序明确要求,否则应避免这种情况,因为通常过大或负值是严重的、不可恢复的逻辑错误,并且捕获是最好的方法。
Cycle 余额 Dos 攻击
由于 IC 的“容器付费”模式,所有容器都容易受到 DoS 攻击,因为它们耗尽了 Cycle 余额,需要考虑到这种风险,最基本的缓解策略是监控容器的 Cycle 余额并使其远离(可配置的)冻结阈值。
在原始 IC 级别上,进一步的缓解策略是可能的:
如果所有更新调用都经过身份验证,请尽快执行此身份验证,可能在解码调用者的参数之前,这样,未经身份验证的攻击者进行的 cycle 消耗攻击就不太有效(但仍然可能)。 此外,实施该 canister_inspect_message 系统方法允许在消息甚至被互联网计算机接受之前执行上述检查,但它不能防御容器间消息,因此不是一个完整的解决方案。 如果预期来自经过身份验证的用户(例如利益相关者)的攻击,则上述方法无效,有效的防御可能需要相对复杂的额外程序逻辑(例如每个调用者的统计信息)来检测这种攻击,并且反应(例如限速)。 如果只有一种方法不适用(例如,未经身份验证的用户注册方法),这种防御是毫无意义的,如果应用程序本质上是可通过这种方式进行攻击的,那么就不值得为其他方法增加防御。 相关:互联网身份不使用 canister_inspect_message 的理由。
Motoko 实现的容器目前无法执行大多数这些防御,参数解码无条件地发生在任何可能基于调用者拒绝消息的用户代码之前,并且 canister_inspect_message 不受支持。
此外,Candid 解码不是非常 cycle 防御性的,人们应该假设可以构造需要许多指令来解码的 Candid 消息,即使对于“简单”参数类型签名也是如此。
经审计的容器的结论是依靠监控来保持 cycle 余额,即使在攻击期间,如果可以承担费用,也许可以祈祷 IC 级 DoS 保护能够发挥作用。
大的数据攻击
如果公共方法允许不值得信任的用户发送无限大小的数据并保留在容器内存中,则存在另一个 DoS 攻击向量。由于将 async-await 代码转换为多个消息处理程序,这不仅适用于明显存储在全局状态中的数据,也适用于跨 await 点的本地数据。
此类攻击的有效性受到互联网计算机的消息大小限制(大约为几兆字节)的限制,但其中许多也会加起来。
如果方法的参数类型允许 Candid 空间炸弹,问题会变得更糟,可以使用 Candid 中的所有值对非常大的向量进行编码 null,因此如果任何方法的参数类型为 [Null] 或者 [?t],则一条小消息将扩展为 Motoko 堆中的一个大值。
其他需要注意的类型:
Nat 和 Int:这是一个无界的自然数,因此可以任意大。然而,Motoko 表示不会比 Candid 编码大很多(因此这不符合太空炸弹的条件)。 仍然建议在存储或执行 await,例如,当它表示数组中的索引时,如果它超过数组的大小,则提前 throw,如果它表示要转移的代币数量,请根据可用余额检查它,如果它表示时间,请根据合理范围检查它。 Principal:一个 Principal 实际上是一个 Blob,接口规范说主体的长度最多为 29 个字节,但 Motoko Candid 解码器目前不检查(在 Motoko 的下一版本中修复)。在此之前,一个 Principal 作为参数传递的可能很大(其中的主体 msg.caller 是系统提供的,因此是安全的)。如果您等不及修复,请在 await 之前手动检查主体的大小(通过 Principal.toBlob)。
msg 或 caller 的阴影
不要为封闭 actor 的“消息上下文”和容器的方法使用相同的名称,编写 shared(msg) actor 是危险的,因为现在 msg 在所有公共方法的范围内。
只要这些也使用 public shared(msg) func …,并因此影响外部 msg,这是正确的,但如果一个人不小心遗漏或错误输入 msg,不会发生编译器错误,但 msg.caller 现在突然成为原始控制器,可能会破坏一个重要的授权步骤。
相反,写 shared(init_msg) actor 或 shared({caller = controller}) actor 避免使用 msg。
结论
如果您编写一个“严肃”的容器,无论是否使用 Motoko,都值得仔细阅读代码并注意这些模式。或者更好的是,让其他人检查您的代码,因为可能很难在您自己的代码中发现问题。
不幸的是,这样的列表永远不会完整,而且肯定有更多的方法可以搞砸你的代码 —— 除了所有非 IC 特定的代码可能出错的方式之外。尽管如此,事情还是在那里完成了,所以祝你好运!
在 smartcontracts.org 开始构建,并在 forum.dfinity.org 加入我们的开发者社区。
作者:Joachim Breitner翻译:Catherine
- 往 期 推 荐 -
微软对 Minecraft 的 NFT 禁令为新的 Web3 游戏创造了机会
长按关注 IC 微信公众号
随时答疑解惑
*添加小助手微信 comiocn 进交流社群