在 Web3.0 领域,智能合约的安全性也会被其部署区块链的设计和运行时环境影响。
这有很多原因,例如:
① 开发者必须使用新的特定领域语言;
② 交易执行可能涉及异步函数最终性;
③ 对于不同的区块链环境,并不总是具有相同的工具。
在本文中,我们将探讨基于不同的运行时模型,智能合约安全性是如何变化的。
本文也将特意比较 EVM 智能合约的运行时环境假设和 NEAR 智能合约的一组类似假设。然后我们将研究这些元素将如何影响一个安全智能合约的设计。
除此之外,本文也将分享我们在审计 NEAR 合约时发现的一个攻击载体,我们将其命名为“insolvency attack”(破产攻击)——这一攻击可以影响那些需要额外支付链上存储费用的区块链。虽然这种攻击已被 NEAR 社区中的部分成员所知,但它仍然不是一种广为人知的攻击方式。
区块链运行时环境
如何影响智能合约安全性?
我们先定义一个设想的流动性质押合约。
通过分析该合约,我们将了解区块链运行时环境是如何影响智能合约安全性的。
这一质押合约具备 ERC20 类型合约中的一些常见功能,共同的功能将是追踪给定用户的 token 余额并允许在用户之间转移 token。
改变状态的函数有 3 个:transfer; transferFrom; approve。
本例中,假设有两个用户 Bob 和 Alice,他们两个通常能够按如下方式更改状态:
transfer
a. 调用:Bob使用如下参数值 (amount=x, to=Alice) 调用transfer。
b. 效果:合约用x个 token 更新Alice的balance,并从Bob的balance中减掉x个 token。
approve
a. 调用:Bob使用如下参数值 (allowance=x, to=Alice) 调用approve,并向 Alice 发送address。
b. 效果:合约根据Bob的balance更新Alice的授权金额x,以允许 Alice 调用 transferfrom转移 Bob 不大于 x 个 token。
transferFrom
a. 调用:Alice 使用如下参数值 (amount=x, from=Bob, to=Sallys) 调用 transferfrom。
b. 效果:合约用x个 token 更新Sally的balance,并从Bob的balance中减掉x个 token,并从Bob对Alice的授权金额中减掉 x。
流动性质押合约的其余函数如下。
depositAndStake
a. 调用:Alice使用如下参数值 (amount=x) 调用depositAndStake。
b. 效果:合约检查当参数值 amount=x时,是否有足够的附加 GAS。如果检查通过,合约将为 Alice 铸造stGAS token 并将 GAS 存入节点。然后调用depositandStakeCallback。
depositandStakeCallback
a. 调用:此函数是一个无参数的合约内部函数,且仅能被合约本身调用。
b. 效果:检查 Gas 存款是否成功。如果成功,它将返回 true,否则会回滚已更新的合约状态并返还用户的 GAS token。
withdraw
a. 调用:Alice使用如下参数值 (amount=x) 调用 withdraw。
b. 效果:合约从节点上取消Alice抵押的 GAS token,并在Alice拥有的等量 stGAS token 上调用transferFrom。如果Alice没有持有足够数量的 stGAS,那么交易将被回滚。
EVM 运行时模型和异步函数的调用
EVM 的Runtime Model(运行时模型)广为人知,Solidity 开发人员假定的一组通用公理如下所示:
① 如果有足够数量的 gas,那么交易可能包含大量复杂的函数调用;
② 如果执行交易,则函数调用是同步的;
③ 如果函数调用增加了现有合约的存储使用量,则交易的 gas 成本没有差异。
这个模型导致了一个有趣的攻击类型「家族」——重入攻击。虽然大家对重入攻击的解决方案已经不陌生,但仍有许多不同的重入仍然并且更难以检测,例如多功能重入和只读重入。
通过我们示例的流动性质押合约,让我们回顾一下简单的重入攻击如何使用下面的伪代码对 EVM-style 的智能合约起作用。
同步函数调用的重入攻击流程:
① 假设地址 Bob 对应一个智能合约,我们的流动性质押合约控制着 100 个 GAS token。
② 如果Bob持有至少 1个 stGAS token,那么 Bob 调用 withdraw 函数并通过回调函数重新进入 withdraw 函数。
③ 最后的 withdraw 将完成函数调用。
check-effect-interact模式是这种类型重入的一种解决方案。
在本例中,这是通过将负责传输的代码行与更新余额的代码行切换来完成的。但是,如果函数调用不再同步,这个解决方案是否仍然安全?
不,Near 智能合约中就可以找到这样的一个例子。
让我们看看下图中用于流动性质押合约的伪代码。我们假设所有外部函数调用都在下一个块中完成(注意,以下内容基于 NEAR 的文档,仅用于模拟重入攻击)。
在上述合约中,withdraw函数遵循check-effect-interact模式,但是它仍然容易受到重入的影响:
○ 假设地址 Bob 对应的是 Near 上的智能合约,并假设我们的流动性质押合约控制 100 个 Near token。
○ 在第 0 个区块中,Bob调用depositAndStake函数并附加 49 个 Near。
Bob 在区块 0 调用 withdraw 函数,然后他将收到49 个 Near。
但是,回调函数depositAndStakeCallback在第一个区块执行,Bob将收到另一份49 个 Near。
请注意,Bob可以在同一笔交易中调用depositAndStake和withdraw。这是因为 NEAR 允许批量函数的调用。这里的关键是withdraw函数是在depositAndStake完成外部函数调用之前完成的。
当函数调用是异步的,重入攻击依然存在,然而攻击形式略有不同。
一种防止此类攻击的解决方案是在某些情况下结合使用Check-Interact-Effect 和本地互斥锁。
请参阅下文了解 Check-Interact-Effect 如何在此处工作。要进一步查看完整示例,请查看 NEAR 文档中的详细解释。
NEAR 运行时模型和 Storage Staking
在本文的上一部分中,我们了解到如果函数调用不是同步执行,会导致我们的智能合约安全模型发生怎样的变化。
而在本章节中,我们可以看看如果随着智能合约状态的增加而增加 gas,安全性将如何变化。
Near 智能合约对应的账户需要持有足够的 NEAR token 用于支付 NEAR 链上数据的存储。这种机制称为Storage Staking。
所有数据都需要存储,包括:账户元数据、智能合约字节码、智能合约上的函数调用生成的数据。Storage Staking 所需的 Near 数量被定义为存储的数据长度乘以字节成本。
因此,我们可以更新Near 开发者的通用公理,如下所示:
① 如果有足够数量的 gas,那么交易可能包含大量复杂的函数调用;
② 如果执行交易,则外部函数调用是异步的;
③ 交易的 gas 成本被定义为函数调用的 gas 成本与Storage Staking费用的总和。如果没有足够的Storage Staking 费用,那么所有函数调用都将还原,并且合约中存入的所有其他资金都将被 Near 链冻结。
同时,我们将介绍一个在编写 Near 智能合约时没有发现的新问题。
如果普通用户可以调用一个在链上存储新数据的函数,那么智能合约逻辑必须验证是否有足够的资金用于Storage Staking。否则,普通用户就可能会对智能合约发起拒绝服务攻击。这种攻击也被称为Million Small Deposits attack。
来自 Near Social 团队的 Evgeny Kuzyakov 为 NEP-145 中的 Storage Staking 提供了解决方案。然而,即使使用这种解决方案,如果未正确计算合约之间的 Storage Staking,这也会导致一个新的攻击向量,我们将其称为 insolvency attack(破产攻击)。
关于 NEP-145 的详情,请参见此链接:https://github.com/near/NEPs/discussions/145
针对流动性质押合约的破产攻击
为了尽量减少复杂程度,假设我们的流动性质押合约不在函数depositAndStake中强制执行Storage Staking费用,withdraw函数会退还Storage Staking费用。
破产攻击步骤
① 假设流动性质押合约包含 100 个 Near,那么 50 个 Near 是 200 个账户的必要存储费。剩下的 50 则是通过质押获得的奖励。
② 进一步假设,一个新账户需要 0.5 个 Near 用于Storage Staking调用 depositAndStake。
③ Bob 输入金额 0.01,调用depositAndStake。
④ 然后 Bob 批量调用withdraw函数,输入金额 0.0001。
⑤ Bob能够从流动性质押中抽取 50 个 NEAR,在这之后,任何在链上存储新数据的函数会被回滚。
尽管以上示例攻击看起来只是微末功夫,但在实践中,检测大型智能合约系统却是个非常困难的事情。
而且这种情况看起来完全不合逻辑。如果balance[user]甚至不为 0,那为什么在调用withdraw时合约会退还 NEAR?
这种情况曾经在审计 NEAR 智能合约时出现过。
例如,跨链桥项目可以在其两侧设置托管合约,以便跨链桥的一侧收取存储费用,而另一侧则释放存储费用。
但是如果跨链桥两侧没有正确的存储记账,用户可以滥用此漏洞获取合约中的存储费用。这是 Calimero 桥的一个风险问题,我们将会在 CertiK 官方公众号接下来发布的内容中详细探讨,如果有现在想要了解的小伙伴,可以访问 certik.com 搜索该项目并点击查看审计报告。
写在最后
智能合约的安全性会受到底层区块链运行时环境,以及定义编程语言的虚拟机的影响。因此不同的区块链需要不同的安全模型。
虽然已经有了大量针对不同区块链的著名安全模型,但随着更多区块链的出现,安全模型的数量也会随之增加。
我们已经看到智能合约安全性在不同模型之间无法组合。在本文中,我们也提供了一些示例来说明 NEAR 和 EVM 智能合约的不同之处。
限于篇幅原因,本文仍未能探讨一些相对重要的方面,例如不同的智能合约安全模型是如何相互交互的。这些内容存在于 bridge 和其他区块链通信系统之间,也因此非常关键,这一主题同样将在未来分享,敬请关注!
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。