SharkTeam:Move语言安全性分析及合约审计要点之权限漏洞、重入攻击
SharkTeam在之前的“十大智能合约安全威胁”系列课程中,根据历史发生的智能合约安全事件,总结分析了在智能合约领域中出现较多、危害最大的前10大漏洞。这些漏洞之前通常出现在Solidity智能合约中,那么对于新兴的Move智能合约来说,会不会存在相同的危害呢?
SharkTeam【 Move 语言安全性分析及合约审计要点】系列课程将和您一起讨论和深入。第一课【权限漏洞及重入攻击】。
1 权限漏洞
权限漏洞是指应用在检查授权时存在纰漏,使得攻击者在获得低权限用户账户后,利用一些方式绕过权限检查,访问或者操作其他用户或者更高权限。
智能合约中的权限漏洞和关键逻辑有关,例如铸造代币、提取资金、更改所有权等。
在Solidity合约中,权限漏洞主要包含以下类型:
· 函数默认可见性
· 缺少modifier验证或验证存在错误或漏洞
· tx.origin身份验证
· 初始化函数问题
· 调用自毁(selfdestruct)
1.1 函数默认可见
在Solidity中,函数的权限有4种,即:private、internal、external 以及 public。
· private:私有函数,只有当前合约内部可见,交易、其他合约和派生合约不可见,仅支持当前合约的内部调用;
· internal:内部函数,只有当前合约和派生合约可见,交易和其他合约不可见,仅支持当前合约和派生合约的内部调用;
· external:外部函数,只有其他合约和交易可见,当前合约和派生合约不可见,仅支持其他合约和发起交易的外部调用;
· public:公有函数,所有账户都可见,包括交易、其他合约、当前合约、派生合约(继承当前合约的合约),支持其他合约和发起交易的外部调用、当前合约和派生合约的内部调用。
Solidity中合约内的函数如果不声明可见性,其默认为public,即所有账户都可见,权限最高。若设置不当,让本应设置为internal/private的函数通过默认设置,成为了public函数,这将会给合约带来极大的威胁,甚至威胁到合约的资产安全和关键业务。
对比Solidity,Move函数拥有更加丰富灵活的可见性,包括public、entry/script、friend 以及 private。
· private:当前Module可见,只有当前模块中的函数可以调用;
· public(friend):模块级别限制,只有当前模块以及友元模块可见,仅可以被当前模块和友元模块中的函数调用;
· public:所有模块和脚本可见,可以被任何模块和脚本中定义的任何函数调用,不能被用作交易使用;
· entry/public(script):所有模块和脚本可见,可以被任何模块和脚本中定义的任何函数调用,还可以作为交易的入口。
Move中函数可见性默认是private,很好地保护了函数的隐私性,比Solidity安全性更高。虽然Move函数默认可见性是安全性,但由于Move的可见性更加灵活,因此也要防范可见性声明错误带来的风险。
1.2 缺少modifier验证或验证存在错误或漏洞
Solidity函数使用关键字 modifier 来声明调用函数需要的一些权限要求。调用函数时,调用者需要满足modifier中指定的权限才能够调用该函数。若是缺少了modifier验证条件或者验证条件中存在错误或漏洞,这使得调用者绕过权限校验来调用函数,这会给合约和资产带来一定的威胁。
Move合约虽然没有modifier,但同样需要特定的权限验证,比如,Move函数使用acquires关键来声明已获取到了资源。此外,Move中的一些权限的验证则是写在了Move规范语言中。这些都可能给Move合约带来一定的风险。
1.3 tx.origin身份验证
Solidity合约中有上下文调用的概念,包括tx.origin 和 msg.sender。
· tx.origin 表示最初的调用者,通常取得的是EOA的地址;
· msg.sender 表示当前的调用者,通常取得是的上级调用者地址,可以是EOA地址,也可以是合约地址。
合约使用全局变量tx.origin作为身份验证凭据,容易被攻击者通过社会工程学诱骗owner签署攻击交易,从而绕过owner身份验证。
Move合约并没有上下文调用的概念。Move合约函数是直接都过地址、模块名称以及函数名称等进行调用的,并不存在调用者的概念。函数的调用权限也是通过函数可见性进行设置的,而不需要细致地验证调用者的身份。因此,Move中不存在这种漏洞。
1.4 初始化函数问题
在Solidity中,初始化函数没有限制调用者或者没有次数限制,容易被攻击者调用,从而对状态变量进行自定义。表面上攻击者只是利用了初始化函数的漏洞,修改了状态变量。其中隐含的实际是状态变量的修改权限漏洞,即状态变量可以被修改,而修改状态变量的函数可以被攻击者利用,然后窃取合约中的数字资产。
从这一角度上讲,Move比 Solidity 要安全,因为Move中的数字资产都是具有唯一性的资源类型,不能被随意地修改和丢失,更加不能被复制。另外,Move中的资源只能被创建它的模块修改,保存在用户账户下的资源只有用户有权限修改。
1.5 调用自毁(selfdestruct)
Solidity合约中,selfdestruct(address payable addr) 函数可以销毁当前合约,并且把当前合约的余额发送给指定地址addr。因此,selfdestruct调用时若没有权限限制,则任意账户都可以调用该函数销毁合约,造成合约剩余的Token的巨大损失,进一步则会引发合约拒绝服务攻击,从而引起及其严重的后果。
相比之下,Move合约中并不存在拥有自毁功能的函数,因此,Move中并不存在这种漏洞。
虽然,Move没有自毁函数,但是,Move中的资源却可以被销毁。Move中的每一种资源都代表着一种数字资产,每次资产的错误销毁都是很大的损失,资源的无故销毁是会给合约以及用户带来安全风险的。因此,要严格控制Move资源的销毁权限,断绝随意销毁资源的可能。
2 重入攻击
重入,从字面上看,指在函数调用过程中,重新调用了当前函数,即执行重新进入了当前函数。Solidity是一种动态的语言,并且在调用call函数后会执行未命名的回调函数 fallback,该函数可以是自定义的函数。这些特性为重入提供了条件。从这一角度来看,重入并不是漏洞。动态调用甚至是重入使得Solidity智能合约可以灵活地实现一些DeFi中的特殊业务,如闪电贷。
以 Uniswap V2 的闪电贷为例,其实现原理就是在swap函数中先转账,然后动态调用业务函数,最后还款(包括手续费),通过K值校验来检查还款金额,从而保证余额满足经济模型以及合约状态的一致性。
如果闪电贷的业务有代币的兑换需求,然后业务函数再次调用了swap函数,于是函数调用的过程中发生了重入。如果没有额外获利,应该不属于重入攻击,因为这只是满足了实际的业务需求。
实际情况是,很难界定一次重入是不是攻击,因为重入很容易破坏“调用外部函数和修改状态变量”的原子性操作。因此,swap函数使用modifier lock以防止重入。
从此来看,重入攻击发生的根本原因是在重入调用外部函数时破坏了“调用外部函数和修改状态变量”的原子性操作,比如调用了两次外部函数,却只修改一次状态变量。
也因此,针对重入漏洞,我们有一条建议就是采用“检查-生效-交互”模式,即先修改状态变量,然后在执行外部函数调用,这样可以保证每一次外部函数调用时都会修改状态变量。
但实际上,对于简单函数,可以这么做,比如函数只调用一次外部函数并且修改一次状态变量。若函数很复杂,其中包含多个外部函数的调用,并且与状态变量更新检查相关的业务同样很复杂,该这种方案就不那么有效了,开发人员就需要采用其他抵御重入攻击的方法,比如添加防重入锁,完全不允许函数重入。
如果从根本原因的角度考虑,我们可以把所有通过破坏”调用外部函数和修改状态变量“的原子性来获取额外收益的交易都视为重入攻击,那么,函数可重入只是为重入攻击提供的一种可能。接下来,我们考虑一下动态调用在重入攻击中有着什么样的作用呢?
什么是动态调用呢?它与静态调用有什么区别呢?
Solidity合约中,外部函数的调用(call)是通过调用合约的地址以及调用函数的函数签名(methodId)来确定的,并且在执行的时候进入到函数的上下文中进行执行,在执行之前的合约编译阶段,并不会加载和编译外部合约的代码,此时的外部函数调用只有一个合约地址和函数签名,其调用逻辑是动态未知的,只有在执行的时候才会根据实际传递的参数动态地确定真正要执行的函数,即传递的参数不同,执行的函数不同。
相比一下,静态调用则是在编译过程中会对每一个函数进行加载编译,整个函数的执行方法和逻辑都是静态已知的。
对于动态执行的函数调用来说,只有在真正执行的时候,才能够真正确定其逻辑是否破坏了”调用外部函数和修改状态变量“的原子性。在此之前,是无法检查和确定的。
静态调用同样可能出现这种情况,但却是可以在编译以及编译之前检查出来的,其更根本的原因是合约业务逻辑上存在漏洞,这才导致了存在破坏”调用外部函数和修改状态变量“的原子性的问题。这种情况是非常容易被发现的。
另外,Solidity动态调用(call)外部函数后会执行回调函数fallback(),该回调函数可以自定义,这个重入攻击提供了必要的条件。
对比动态调用和静态调用,我们总结出以下结论:
Ÿ 动态调用是重入攻击的必要条件,合约只有动态执行的情况下,才有可能发生重入攻击。
Ÿ 在静态调用的合约中,不会发生重入攻击,但可能存在类似的业务实现漏洞,但不再是重入漏洞。
通过以上分析,我们重新梳理了重入攻击的概念,如下:
智能合约中的重入攻击是指攻击者利用外部函数动态调用的特性,通过自定义回调函数,破坏了”调用外部函数和修改状态变量“的原子性,从而获取额外收益攻击手段。
Move是一种静态调用的言语,而且不支持回调函数fallback(),因此Move合约中是不可能发生重入攻击的,但这不代表着高枕无忧。通过以上分析,Move虽然没有发起重入攻击的条件,但可能会存在原因类似的业务逻辑漏洞,尽管这种漏洞比较低级,并且很容易被发现,但仍然是可能存在的,尤其是对于初学者以及粗心大意的开发人员。Move资源的唯一性以及存储特性也在一定程度上可以避免一些因为业务漏洞引起的资产损失。
· Move资源是唯一的。Move代币是一个代表资源的结构类型,其中保存着代币的数量。数量是值类型,指定数量的代币是资源,对于代币的发行则是以资源类型的代币为原子单位,不能随意地铸造、销毁,更不能复制。Move的代币转账并像Solidity中只是简单的数值上的加和减,Move代币最主要的是通过资源的铸造和销毁来实现转账。
· Move的代币资源在铸造后是存储在用户个人账户中的,只有用户自己有权处置(转移等)这些代币资源。而在Solidity合约中,代币余额是一个值,存储在代币合约中的状态变量中,若合约有漏洞,这些代币可以被复制和转移。
由此可见,Move语言从多个层面上保障了用户资产的安全性。
3 总结
当下Move仍处于发展阶段,Move生态离成熟尚一定距离,开发者较少,开发者经验欠缺,真正能够熟练开发Move合约的不多,因此更容易出现业务层面的一些漏洞。这需要Move合约在设计和开发过程中对Move语言特性以及业务都要熟悉,才可能少出现业务漏洞。
另外,Solidity已经实现了大量的业务类型,比如去中心化交易所、去中心化借贷、收益聚合、杠杆借贷、杠杆挖矿、闪电贷、跨链交易等。这些典型的业务场景需要在Move生态足逐一实现,而且需要结合Move与Solidity的差异进行重新设计实现方案。在这个过程中,就比较容易出现一下漏洞,就像Solidity早期经历了很多次攻击和大量资产的损失才逐步走向成熟。Move虽然是一个安全性较高的语言,但谁也无法保证没有漏洞,我们希望可以借鉴Solidity的发展过程,让Move生态的发展少走一些弯路,少一些损失,更快更稳地走向成熟。
About Us
SharkTeam 的愿景是全面保护Web3世界的安全。团队由来自世界各地的经验丰富的安全专业人士和高级研究人员组成,精通区块链和智能合约的底层理论,提供包括智能合约审计、链上分析、应急响应等服务。已与区块链生态系统各个领域的关键参与者,如Polkadot、Moonbeam、polygon、OKC、Huobi Global、imToken、ChainIDE等建立长期合作关系。