查看原文
其他

【硬核文章】Dfinity前工程师教你如何审查IC Canister

DfinitySZ DfinitySZ 2022-03-21

文章来自于|Joachim Breitner

翻译|DfinitySZ

投稿、转载请联系|DfinitySZ小助手


Dfinity前工程师(Joachim Breitner)最近在审计ORIGYN Canisters的过程中总结了一份在开发时需要注意的事项清单和些许建议(这份事项清单的公布已得到ORIGYN的允许)。



Canister间调用

 

互联网计算机系统提供遵循actor模型的容器间通信:容器间调用通过两个异步消息实现,一个用于发起调用,另一个用于返回响应。容器是以原子方式处理消息(并在某些错误情况下回滚),但不完成调用。使得使用容器间调用的编程容易出错。 而出现错误、漏洞或简单的意外行为可能是以下原因造成的:


  • 在发起容器间调用之前读取全局状态,并假设它在调用返回时仍然保持读取;

  • 在发起容器间调用之前更改全局状态,并在响应处理程序中再次更改它,但假设没有任何其他状况更改其之间的状态(重入攻击);

  • 在发起容器间调用之前更改全局状态,并且没有正确处理故障,例如当处理回调的代码回滚时;


如果在你的代码中发现了这样的模式,你应该分析恶意方是否可以触发它们,并评估影响的严重性。


这些问题适用于所有容器,但这并不是 Motoko 特有的。



回滚


即使在没有进行容器间调用的情况下,回滚的特性也会令人吃惊。特别是拒绝(即throw)回滚完成之前所做的状态变化,而trapping(例如Debug.trap、assert ...、out of cycle conditions)可以。


因此,我们应该检查所有的公共更新调用入口点,看看是否有不需要的状态更改或不需要的回滚。特别是寻找能在状态更改后出现throw的方法(或者说消息,即提交点之间的代码)。
此问题适用于所有容器,但这并不是 Motoko 特有的,尽管其他 CDK 可能不会将异常转换为拒绝(不会回滚)。



与恶意Canister交互


由于以下原因(可能不完整),与恶意容器交互可能存在风险:


  • 另一个容器可以拒绝响应。虽然互联网计算机的双向消息通信规范旨在保证最终响应,但是只要对方愿意在响应之前付费,就可以进行忙循环。更糟糕的是,此外还有很多方法可以使容器宕机;

  • 另一个容器可以用无效编码的Candid 进行响应。这将导致 Motoko 实现的容器陷入回复处理程序,从而无法轻松恢复。其他CDK可能会提供更好的方法来处理无效的 Candid,但即便如此,仍必须担心Candid Cycles炸弹会导致回复处理程序陷入困境。


许多容器甚至可以不进行容器间调用,只调用其他可信赖的容器即可。(这是一个需要评估的可选项)。



Canister升级:概述


对于大多数服务而言,能够可靠地升级是至关重要的。这可以分为以下几个方面:


1. Canister可以升级吗?2. Canister升级会保留所有数据吗?3. Canister能及时升级吗?4. 当无法升级时,是否有三个恢复方案?



Canister可升级性


无论出于何种原因,在 canister_preupgrade 系统方法中陷入困境的容器都不可再升级。 这是一个重大风险。


Motoko 容器的 canister_preupgrade 方法由任意系统 func preupgrade() 块中编写的代码组成。然后系统生成的代码将任意稳定变量内容序列化为二进制格式,并复制到稳定内存。


由于 Motoko 内部序列化代码会先序列化到主堆中的暂存空间,然后再将其复制到稳定内存中,因此具有超过 2GB 实时数据的容器可能无法升级。这不太可能是第一个限制(Canister可以升级吗?):


系统对升级容器施加指令限制:此限制是一个子网配置值,与正常的每条消息限制分开(可能更高),并且不容易确定。如果容器的实时数据变得太大会无法在此限制内进行序列化,容器将变得不可升级。


只要使用 Motoko 和稳定变量,就无法完全消除这种风险。该风险可通过适当的负载测试来缓解:


安装一个容器,用实时数据填充它,然后进行升级。如果这在实时数据集超出预期数据量的情况下成功,那么这种风险可能是可接受的。添加功能性的奖励积分将阻止容器的实时数据超过特定大小。
如果要在本地副本上进行此测试,需要注意确保本地副本实际执行指令计数和具有与生产子网相同的资源限制。


另一种缓解措施是尽可能避免canister_pre_upgrade 方法。这意味着不使用稳定的变量(限制小的和固定大小的配置数据),除此之外其他数据都可以:

  • 映射容器(可能是链下),并在升级后手动再水合化;

  • 在每次更新调用期间,使用ExperimentalStableMemoryAPI手动存储在稳定内存中,虽然这与高保证 Rust 容器(例如 Internet Identity)所做的相匹配,但这需要对数据进行手动二进制编码,并且标记为实验性的,因此Dfinity前工程师(Joachim Breitner)目前不建议这样做;

  • 在 Motoko 有稳定变量的可扩展解决方案之前,不要放入 Motoko 器(例如,将它们永久保存在稳定内存中,在主内存中使用智能缓存,从而消除对预升级代码的需求)。


升级时的数据保留


容器在升级期间应该保留所有实时数据。Motoko 会自动确保这一点以获得稳定的变量数据。但是容器通常希望以不同的格式处理他们的数据(例如,在未共享的对象中,不能放入稳定的变量中,例如 HashMap 或 Buffer 对象),因此需要遵循以下习惯用法:


    stable var fooStable = …;     var foo = fooFromStable(fooStable);     system func preu    pgrade() { fooStable := fooToStable(foo); })     system func postupgrade() { fooStable := (empty); })


在这种情况下,更重要的是检查:


  • 所有不稳定的全局变量,或具有可变值的全局变量,都有一个稳定的伴侣;

  • 不会遗忘foo和foostable的赋值;

  • 以fooToStable和fooFromStable形式双射。


一个示例是通过Iter.toArray(....entries()) 和HashMap.fromIter(....vals()) 存储为数组的HashMap。

 

值得指出的是,代码视图只会查看代码的单个版本,而无法检查代码更改是否会保留升级时的数据。如果以不兼容的方式更改稳定变量的名称和类型,这很容易出错。在这种情况下升级可能会失败,在糟糕的情况下,升级甚至也可能会成功,但是会在此过程中丢失数据。这种风险需要通过彻底的测试和备份来减轻(见下文)。



及时升级


当 Motoko或Rust容器仍在等待对容器间调用的响应时,它们是无法安全升级(回调最终会到达新实例,由于 IC系统API的缺陷,可能会调用任意内部函数)。因此,在升级前需要停止容器,然后重新启动。如果容器间调用需要很长时间,这意味着升级可能需要很长时间,这可能是不可取的。同样,如果对可信容器进行所有调用,则该风险会降低,而如果对不可信的容器被直接或间接调用时,这种风险会升高。



备份和恢复


由于上述提到在容器升级时可能会面临到的风险,Dfinity前工程师(Joachim Breitner)的建议是采用恢复策略,以便可以重新安装(而不是升级)容器并重新上传所有数据。此过程可能涉及所有相关数据的链下(或其他备份方案)备份。


重新安装与上面“提示升级”中描述的升级存在相同的问题:为了安全,应该先停止它。

注意:消息指令限制及消息大小限制都会限制返回的数据量。如果容器需要保存更多的数据,备份查询方法可能必须返回块或增量,以及所有额外的复杂性,例如下载块之间的状态更改。


如果进行大数据负载测试(Dfinity前工程师Joachim Breitner推荐测试可升级性),这样可以测试备份查询方法是否在资源限制内有效。



时间并不单调


互联网计算机提供给容器“当前时间”的时间戳保证是单调的,但不是严格单调的。即使在相同的消息中,只要它们在同一个块中处理,它就可以返回相同的值。因此,它不应该用于检测“发生在之前”的关系。


与其通过使用及比较时间戳来检查Y是否在X最终发生后被执行,不如引入一个显式的var y_done : Bool状态,它先被X设置为False,然后再被Y设置为True。当事情变得更复杂时,通过speaking tag names来模拟该状态会更容易,并顺带更新 "状态机"。


此问题的另一个解决方案是引入一个var v : Nat计数器,在每个更新方法和在每次等待之后使用该计数器。现在V是容器状态计数器,可以在很多方面像时间戳一样使用。


当我们谈论时间时:系统时间(通常)在等待期间发生变化。因此,如果你确实让 now = Time.now() 然后等待,那么 now 中的值可能不再是你想要的。



封装算法


Nat64数据类型和其它固定宽度的数字类型提供可选的封装算法(例如 +%、fromIntWrap)。除非当前应用程序明确要求,否则应该避免这种情况,因为太大值或负值通常是严重和不可恢复的逻辑错误,而trapping是最好的方法。



Cycles  balance drain attacks


由于IC的“容器付费”模式,所有容器都容易通过耗尽其周期余额而遭受DoS攻击,在开发时需要考虑这种风险。


最基本的缓解策略是监控容器的Cycles平衡并使其远离(可配置的)冻结阈值。


在原始 IC-level,可能有进一步的缓解策略:


  • 如果所有更新调用都经过身份验证(请尽快执行此身份验证),可能在解码调用者的参数之前。这样,未经身份验证的攻击者进行的Cycles消耗攻击就不太有效(但仍有可能);

  • 此外,实现 canister_inspect_message 系统方法允许在消息在被互联网计算机接受之前执行上述检查。但它不能防御容器之间的消息,因此不是一个完整的解决方案; 

  • 如果来自经过身份验证的用户(例如利益相关者)的攻击,则上述方法无效,有效的防御可能需要涉及的额外程序逻辑(例如每个调用者的统计信息)来检测此类攻击,并做出反应(例如限速);

  • 但是只有一种方法是不适用的(例如未经身份验证的用户注册方法),则此类防御毫无意义。如果应用程序天生就可以通过这种方式攻击,那么为其他方法提高防御是不值得的。


相关:为什么互联网身份不使用 canister_inspect_message 的理由):

https://github.com/dfinity/internet-identity/blob/49a9ae3b2ab911e0d0331d8cdd2d67e97c94588a/docs/internet-identity-spec.adoc#why-we-do-not-use-canister_inspect_message


Motoko实现的容器目前无法执行大多数这些防御:参数解码会无条件地发生在任何可能基于调用者拒绝消息的用户代码之前,并且不支持 canister_inspect_message。此外,Candid 解码不是很防御Cycles,并且应该假设可以构造需要许多指令来解码的 Candid 消息,即使对于“简单”参数类型签名也是如此。


Dfinity前工程师(Joachim Breitner)此次审计容器的结论是依靠监控来保持Cycles平衡,即使在攻击期间,如果可以承担费用,也许会启动IC-level DoS保护。



大数据攻击


如果公共方法允许不受信任的用户发送永久保存在容器内存中的无限大小数据,则存在另一个 DoS 攻击向量。由于将 async-await 代码转换为多个消息处理程序,这不仅适用于存储在全局状态中的数据,还适用于跨越等待点的本地数据。


类攻击的有效性会受到互联网计算机的消息大小限制(大约为几兆字节),但其中的许多消息也会叠加。


如果方法具有允许 Candid 空间炸弹的参数类型,则问题会变得更糟:这意味着可以在 Candid中编码非常大的向量,所有值都为 null,因此如果任何方法具有 [Null] 或 [?t] 类型的参数,一个小消息会在 Motoko 堆中扩展为一个大值。


其他需要注意的类型:


Nat 和 Int:这是一个无界自然数,因此可以任意大。然而,Motoko 表示不会比 Candid 编码大多少(因此这不符合空间炸弹的要求)。


仍然建议在存储或等待之前检查数字的大小是否合理。例如,当它表示数组中的一个索引时,如果超过了数组的大小,则提前throw;如果它表示要转移的代币数量,请根据可用余额检查它,如果它表示时间,请根据合理范围检查它。


Principal:Principal实际上是一个 Blob。在接口规范里Principal的长度最多为 29 个字节,但 Motoko Candid 解码器当前是不会检查的(在 Motoko 的下一个版本中修复)。在此之前,作为参数传递的 Principal 可能很大(msg.caller 中的Principal是系统提供的,因此是安全的)。如果你不能等待修复程序到达手中,请在等待之前手动检查princpal的大小(通过 Principal.toBlob)。


接口规范:

https://sdk.dfinity.org/docs/interface-spec/index.html#principal
Motoko Candid解码器:https://github.com/dfinity/motoko/blob/6feb52696bd7c446a7fe5231519045dd6d248bdc/src/codegen/compile.ml#L5031 



mag或caller的阴影


不要对封闭 actor 的“消息上下文”和容器的方法使用相同的名称:编写 shared(msg) actor 是危险的,因为现在 msg 在所有公共方法的范围内。只要使用public shared(msg) func ...,遮蔽了外部msg,那么它是正确的。如果不小心遗漏或错误键入了msg是不会发生编译器错误,但突然msg.caller会现在成为原始控制者,这可能会击败重要的授权步骤。
相反,编写 shared(init_msg) actor 或 shared({caller = controller}) actor 可以避免使用 msg。



结论




如果你编写了一个“严肃”的容器,无论是否使用 ‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍otoko,都值得仔细阅读代码并注意这些模式。




必看周刊


生态精选


寻宝回顾


精彩活动


联系我们

 电报 

        t.me/DfinitySZ

 官方网站

        dfisz.com

 英文推特 

        twitter.com/DfinitySZ

 中文推特 

        twitter.com/DfinitySZCN

 英文论坛 

        reddit.com/user/DfinityShenZhen


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存