Flow Community Rewards are here. Earn points for engaging in the ecosystem, spend points on prizes. Learn more.
开发者生态
2023 年 10 月 31 日
我们本可以如何防止这一 Solidity 漏洞?DAO
约书亚-汉南
我们本可以如何防止这一 Solidity 漏洞?DAO

欢迎阅读我的系列博客,了解 Solidity 智能合约中常见的高知名度漏洞。在这个系列中,我将解释 Solidity 智能合约中的不同漏洞是如何被利用的,以及选择 Solidity 这样的语言实际上是如何让开发人员从一开始就陷入困境的。Solidity 要求开发人员学习所有额外的编码模式和最佳实践,以编写安全代码。这些编码模式和最佳实践很难理解和实施,同时还要保持合约的性能和清晰度。你不能相信所有开发人员都能做到这一点。开发人员应该专注于构建出色的体验,而不是修补安全漏洞。我知道。我就是其中一员。 

我从 2017 年初开始进入 web3 智能合约领域,在从事模块化库和首个交互式 ICO 实现等项目的同时,将自己打造成了一名 solidity 开发人员和安全审计员。我经历了智能合约的大部分黑客攻击事件,因此我很清楚黑客攻击会给用户和开发者带来怎样的负面影响。我于 2019 年加入 Flow 团队,从那时起就一直致力于设计更安全的智能合约体验。

DAO 黑客

我要讨论的第一个黑客是 "The DAO",它是加密货币历史上的一个重要事件。DAO,即去中心化自治组织,是一个基于区块链的投资基金,2016 年通过代币销售筹集了 1.5 亿美元的以太币。以太币由代币持有者集体管理的智能合约持有。不幸的是,DAO遭到了黑客攻击,黑客设法从智能合约中抽走了大部分资金。黑客使用了现在众所周知的 "重入攻击",即黑客递归地从智能合约中提取资金,而不更新其余额。

下面是一个 Solidity 合约的基本示例,可以通过重入功能进行破解。DAO 稍微复杂一些,但原理是一样的。

Contract
GroupEtherBank {

       uint256 public withdrawalLimit = 1 ether;
       mapping(address => uint256) public lastWithdrawTime;
       mapping(address => uint256) public balances;

       function depositFunds() public payable {
              balances[msg.sender] += msg.value;
       }

       function withdrawFunds (uint256 _weiToWithdraw) public {
           // make sure the caller has enough balance to withdraw
           require(balances[msg.sender] >= _weiToWithdraw);
           // make sure they aren’t withdrawing more than the limit
           require(_weiToWithdraw <= withdrawalLimit);
           // Make sure they have waited long enough to withdraw
           require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
           // Send the Ether to the caller
           require(msg.sender.call.value(_weiToWithdraw)());
           // Update the caller’s balance and last withdraw time
           balances[msg.sender] -= _weiToWithdraw;
           lastWithdrawTime[msg.sender] = now;
       }
}

在使用 msg.sender.call 发送 ETH 并更新合约中的余额之前,提取方法会首先验证调用者是否有权提取代币。看起来没问题吧?不对!我告诉过你不要相信我!

漏洞在于 Solidity 奇怪的内置回退功能。  

回退函数是一个可以包含在智能合约中的函数,当以太币被发送到它时,它会执行一些代码,而不会调用相应的函数。

contract FallbackExample {

    boolean public calledFallbackFunction;

    fallback() external payable {
        calledFallbackFunction = true;
    }
}

创造性地使用回退函数是许多 Solidity 智能合约被黑客攻击的媒介。

被黑客攻击的合约没有回退功能,而黑客攻击的合约却有。在银行合约的提款函数中,余额和其他值是在以太币发送后而不是之前更新的。

   require(msg.sender.call.value(_weiToWithdraw)());
// 更新调用者的余额和最后提款时间
balances[msg.sender] -= _weiToWithdraw;

攻击者部署了一个伪装成 "投资者 "的智能合约,并向 DAO 存入了一些 ETH。这使得黑客后来可以调用 DAO 智能合约中的 withdraw() 函数。当 withdraw() 函数最终被调用时,DAO 的合约向黑客发送了 ETH。然而,黑客的智能合约故意没有 receive() 函数,因此当它收到来自 withdraw 请求的 ETH 时,黑客的回退函数就被触发了。这样,黑客的回退函数就可以反复调用 withdraw 方法,从 DAO 合约中提取资金。由于发送发生在余额更新之前,因此从未达到余额,所有代币都被从 DAO 中提取。

重入攻击

来自 https://www.geeksforgeeks.org/reentrancy-attack-in-smart-contracts/#

重重性的形式多种多样,是智能合约的常见漏洞,它可能存在于各种区块链平台的智能合约中,其他语言如 Vyper 和 Rust 也容易出现这个问题。当然,你可以告诉开发人员使用正确的编码模式,比如把余额更新移到以太币转账之前,这样就可以避免。但我们已经发现,大多数开发人员都很懒惰,不可信任。举个例子,我用人工智能写了这篇博客的一个段落。猜猜是哪个?开个玩笑,请关注实际内容!(我们也很容易分心)

幸运的是,自 2016 年以来,智能合约工程标准已经有了很大改进,有一种新的编程范式被称为面向资源的编程,它有助于解决其中的许多问题,同时也更加依赖于强大的类型系统和其他在当时并不明显的改进。近年来,针对 Flow、Aptos 和 Sui 等区块链出现了 Cadence 和 Move 等面向资源的语言,让智能合约开发者的生活变得更加轻松。

用 Cadence 解决重入问题

像Cadence这样面向资源的编程语言如何帮助像我这样懒惰的开发人员避免像DAO黑客这样的漏洞?正如你在本系列的大多数文章中所看到的,当你开始使用Cadence进行开发时,你就获得了一种超能力。除了学习这门语言,你不需要做任何额外的工作。它只是默认的。这种超能力就是,为了避免这些漏洞,你不需要阅读文档页面中每个功能的晦涩角落,也不需要使用特定的安全库。你能获得安全保障,仅仅是因为你使用了一种更好的语言。

我知道,谁会想到有些语言比其他语言更适合不同的领域,尤其是当这些语言是用 10 年来从使用其他语言中获得的知识构建而成的时候!

在像 Cadence 这样的强类型资源导向语言中,安全保护措施已经内置于语言本身,因此开发者必须主动打算在其智能合约中构建大部分这些漏洞,才有可能受到这些漏洞的影响。这样,开发人员就可以专注于真正重要的事情,即为用户提供良好的体验。

首先,Cadence 没有回退功能,因此这种确切版本的漏洞根本无从谈起,但即使有类似的漏洞,Cadence 也有额外的保护措施,使开发人员和用户更加安全。

让我举例说明 Group Bank 智能合约在 Cadence 中的工作原理。为了让新手更容易理解,我将使用简化的 Cadence 代码。

像 Solidity 这样的语言与面向资源的语言最大的区别在于如何跟踪资产的所有权。在包括Solidity在内的大多数语言中,所有权都是通过中央位置的分类账来跟踪的。当你转移代币时,你在分类账中的余额会减少,而你要转移代币的地址的余额会增加。

在面向资源的语言中,资产实际上表示为直接存储在账户存储空间中的对象,称为资源。这与现实世界更为相似,因为在面向资源的语言中,资产就像你存放在家里或钱包里的实物或现金。当你想转移资产所有权时,你只需将它们从你的账户存储空间转移到你要转移的人的账户存储空间即可。去中心化程度更高!

标题:将资产与它们在现实世界中的运作方式进行比较,实际上有助于思考在 Cadence 中设计智能合约的许多不同方面!

你可能会问,这和防止重入有什么关系? 

一个重要的考虑因素是,在这种模式下,每种资产都会存储自己的元数据,包括余额。因此,Cadence 中跟踪可替代代币余额(如以太币)的对象看起来会是这样的:

access(all) resource Vault {
 
   access(all) var balance: UFix64

}

就像现金钞票上有一个数字表示它值多少钱一样,每个金库上也有一个余额字段表示它值多少钱。但与真正的现金不同的是,我可以将多个金库合并成一个金库,其中包含合并后的余额总和!不幸的是,你不能通过把两张钞票砸在一起来实现这个功能。相信我,我已经试过了。


反过来也是一样,在转移代币时,一个金库会被分成两个金库,这样你就可以保留不转移的代币,而第二个金库则可以转移到不同的账户。

标题:免责声明:请勿尝试在现实生活中将现金钞票撕成两半。

下面是在 Vault 资源中定义的函数,调用该函数可以实现这一目的:

       // Only the person who literally has possession of the Vault object can call this
       access(Withdrawable) fun withdraw(amount: UFix64): @Vault {
            self.balance = self.balance - amount
            return <-create Vault(balance: amount)
        }

将其与上述稳固性合约中的提取功能进行比较:

// Two operations are required to move tokens
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;

正如您在 Cadence 代码中看到的,由于我们实际上必须在函数的返回语句中返回已提取的 Vault,因此函数无法重新输入提取方法。此外,可互换令牌标准有一个内置的先决条件,要求必须有足够的余额才能提取,因此,在攻击者能够提取所有资金之前,也会检查提取条件并使其失败。 

Cadence 的银行合同

下面是如何在 Cadence 中建立一个类似上述的简单银行:

access(all) contract GroupFLOWBank {

    /// Dictionary (Mapping) that stores each users' deposits
    /// Key: The ID of the user who has deposited
    /// Value: The Vault that the user deposited
    access(contract) let deposits: @{UInt64: FlowToken.Vault}

    /// Any user can call deposit to store their vault here
    /// They get a resource object back that they can use to withdraw their deposit
    access(all) fun deposit(from: @FlowToken.Vault): @DepositManager {
        let manager <- create DepositManager()

        // Store the depositor's vault at the UUID of their DepositManager resource
        self.deposits[manager.uuid] <- from

        // Return their manager to them
        return <-manager
    }

    /// Object that the owner of a deposited vault stores in their private account
    /// that gives only them access to only the vault that they deposited
    access(all) resource DepositManager {
        access(all) fun withdraw(amount: UFix64): @FlowToken.Vault {
            // Get a reference to the vault that they deposited
            let depositedVaultRef = (&GroupFLOWBank.deposits[self.uuid] as &FlowToken.Vault?)!

            // Return the amount of FLOW they want to withdraw
            return depositedVaultRef.withdraw(amount: amount)
        }
    }
}

如果你以前从未见过面向资源的编程语言,这可能会让你有点困惑。在 Cadence 合约中,大部分功能都是在资源中定义的,因此默认情况下并不公开,因为资源存储在账户的私有存储空间中。

在银行合约中,每个用户存入的代币都存储在自己的保管库(而不是全局池)中,只有他们才能调用自己的提取方法,因为提取方法定义在他们存储在私人账户存储空间中的资源对象中,而不是像在 Solidity 中那样定义在公共函数中!此外,由于提款方法与资源绑定,它只能访问以其资源 UUID 存储的相应 Vault。此外,由于余额的减少与提款过程密不可分,可重入函数无法再次调用提款方法,因为代币已经被减去并移走了。恶意提现将直接失败。 

总结

我希望读完这篇文章后,你能更多地理解为什么面向资源的编程是构建智能合约的一种更安全、更稳健的方式。我们使用的编程语言应该帮助我们避免潜在的问题,而不是让我们无助地犯下简单的编码错误,从而导致我们损失数百万美元。对于面向资源的编程方式,我只是略知一二,但我将在今后的文章中探讨更多,请订阅并保持关注!

我强烈建议您查看 Cadence 文档和教程,进一步了解这种编程范式。下次再见