以太坊上的Solidity和智能合约漏洞

  • QuickNode
  • 发布于 2025-02-05 14:16
  • 阅读 45

这篇文章深入探讨了Ethereum智能合约的安全性,具体阐述了Solidity语言中的常见漏洞,例如重入攻击、算术溢出和访问控制问题。文章提供了理论背景和具体的代码示例,并给出了缓解这些安全问题的方法,通过这样的方式帮助开发者理解并实现安全的智能合约编写。

概述

以太坊的每日交易量反映了其在 web3 发展中的关键角色,但这也突显了如果不采取安全措施,可能面临的潜在价值风险。本指南将专注于智能合约中漏洞的高层次概念,并展示在 EVM 兼容区块链上创建智能合约的主要语言 Solidity 中发现的智能合约漏洞。我们还将涵盖开发者可能遇到的常见陷阱。通过深入了解 Solidity 智能合约漏洞的理论和概念,你将获得最佳实践和预防措施的见解。

让我们开始吧!

你将要做的事情

  • 探索智能合约中的漏洞背后的理论
  • 了解常见的 Solidity 智能合约漏洞
  • 参加测验以挑战你的知识

你需要的准备

智能合约脆弱性理论

智能合约可能因不同原因而脆弱。例如,你的区块链应用程序可能包含错误的业务逻辑、存在不安全的代码,或者与外部依赖关系和交互有问题,从而导致意外行为。这些都是如果没有得到妥善设计,你的智能合约可能面临的不同攻击向量。

从本质上讲,像 Solidity 这样的智能合约语言被编译成字节码,这是部署在以太坊区块链上的内容,也是以太坊虚拟机(EVM)理解的内容。其他智能合约语言如 Vyper(使用 Python)在设计时考虑了一些安全因素,但以牺牲某些特性和较少的开发者支持为代价。还有更低级别的智能合约语言,如 Yul,允许编写更优化和节省 gas 的智能合约,不过代价是复杂性。在选择智能合约语言时,这种权衡对于开发者而言可能是一个重要因素,这也是为什么 Solidity 仍然是以太坊(及其他 EVM 链)上的首选智能合约语言的原因。

另一个需要记住的观点是,智能合约语言并不是唯一的攻击向量,它也可能依赖于区块链执行设计。我们将不在本指南中讨论这一点,但值得牢记。

为什么会发生黑客攻击?

在我们深入本指南的技术部分之前,让我们谈谈为什么会发生黑客攻击。

黑客攻击是智能合约漏洞的结果。然而,它们仍然可以被视为多方面的问题,如前面所描述的错误业务逻辑、不安全的代码或与外部因素的不当交互。尽管技术改进和开发者经验对于防止攻击至关重要,但理解人类因素——无论是用户还是攻击者——同样至关重要。话虽如此,接下来的本指南将专注于 Solidity 漏洞,并帮助你更好地理解如何从概念上编写安全的 Solidity 智能合约。

重新进入攻击

重新进入攻击是理解智能合约漏洞最重要的方面之一。这种漏洞可以通过 Solidity 和 Vyper 产生,发生在智能合约中的某个函数在执行完成之前就向另一个合约发出了外部调用。如果外部合约回调到原始函数,如果不是安全编写,则原始函数可能在不一致或意外的状态下执行(我们将在不久后详细解释)。成功的重新进入攻击的结果不仅限于资金被盗,还可能导致未经授权的函数调用或合约状态的更改。

以下是易受攻击代码的示例:

function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount);

    (bool sent, ) = msg.sender.call{value: _amount}(""); // 在状态更新前执行外部调用
    require(sent, "发送以太失败");

    balances[msg.sender] -= _amount; // 在外部调用后更新状态
}

在上面的 withdraw 函数中,函数在更新用户余额之前发送 Ether (msg.sender.call)。操作顺序使得合约易受重新进入攻击。

缓解重新进入攻击的脆弱性

你可以使用一些解决方案来缓解这种不安全操作顺序的类型(按重要性排序):

  1. 检查-效果-交互模式:在调用外部合约或发送 Ether 之前,始终更新合约的状态(余额、内部变量)。应用于上述易受攻击代码示例的这种模式看起来如下:
function withdraw(uint256 _amount) public {
    // 检查
    require(balances[msg.sender] >= _amount, "余额不足");

    // 效果
    balances[msg.sender] -= _amount; // 在交互之前更新状态

    // 交互
    (bool sent, ) = msg.sender.call{value: _amount}("");
    require(sent, "发送以太失败");
}
  1. 函数保护:在你的智能合约中声明一个修饰符,作为函数调用的锁,使用一个布尔变量来锁定和解锁对你函数的回调。如果使用函数保护,理想情况下,你仍然应该使用最佳实践(检查-效果),但这就是另一种安全层次。然而,防止重新进入的函数保护看起来与此类似:
bool private locked;

modifier noReentrant() {
    require(!locked, "不允许重入!");
    locked = true;
    _;
    locked = false;
}

function withdraw(uint256 _amount) public noReentrant {
    ...
}

算术溢出和下溢

大多数智能合约库会帮助你管理算术溢出和下溢;然而,如果你与构建在较旧版本 Solidity(例如 < 0.8.0)上的智能合约交互,请注意这种漏洞。

算术溢出或下溢的一个简单示例是,当一个数字回滚到其最小/最大值时。例如,uint8 值可以在 0 到 255 范围内。如果你的智能合约之前没有正确管理该值的状态,它可能会潜在地重置为 0,或者反之,达到 uint8 可以到达的最大值 255。

缓解算术溢出和下溢的脆弱性

  1. 数学库:在你的智能合约中使用如 SafeMath 等库以防止算术溢出和下溢。
  2. 函数保护:在你的智能合约中声明一个修饰符,以在允许更改状态之前检查下溢或溢出。

注意:请记得在编译新的智能合约时使用 Solidity 版本 0.8.0>

访问控制

智能合约可能不希望其所有函数都可以被任何人调用。因此,访问控制在保护智能合约中起着关键作用。访问控制漏洞的一个例子可能是,一个智能合约开发者忘记保护 DAO 控制功能,因此任何人都可以调用该功能,可能会更改 DAO 规则。

以下是访问控制问题如何在 Solidity 合约中表现出来的分析:

contract UpgradeableContract {
    // ...
    function updateLogicAddress(address _newAddress) public {
        // 应该限制为仅拥有者调用
        logicAddress = _newAddress;
    }
}

在上面的代码示例中,任何人都可以调用 updateLogicAddress,可能会升级智能合约的新实现逻辑,包含恶意代码。对此潜在漏洞的解决方案如下。

缓解访问控制脆弱性

  1. 函数保护:创建一个修饰符,检查调用者是否被授权。这可以实现为:
modifier onlyOwner() {
    require(msg.sender == owner, "不是所有者");
    _;
}

然后将你的原始合约恢复如下:

function updateLogicAddress() public onlyOwner {
    // ...
}
  1. 基于角色的访问控制系统 (RBAC):在处理包含复杂权限的智能合约时非常有用。像 OpenZeppelin 这样的工具提供了可以使用的 RBAC 系统。在下面的示例中,我们导入一个 Ownable 合约并将其设置为提供的 initialOwner 参数。这个解决方案消除了创建你自己的身份验证修饰符的需要。
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyContract is Ownable {
    constructor(address initialOwner) Ownable(initialOwner) {}

    function specialThing() public onlyOwner {
        // 只有所有者可以调用 specialThing()!
    }
}

使用 tx.origin 的钓鱼攻击

tx.origin 是开发者可以使用的众多内置 Solidity 关键字之一。tx.origin 返回交易发起者的地址。例如,如果合约 A 调用合约 B,则 tx.origin 的值将是合约 A 的地址。然而,如果合约 A 调用 B,然后 B 调用 C,则 tx.origin 将始终是发起交易的地址(即 A)。如你所想,这可能会导致一个实体欺骗函数调用,充当所有者。

钓鱼攻击的缓解

  1. 使用 msg.sender:尽可能使用 msg.sender 代替 tx.origin。msg.sender 指的是函数的直接调用者,提供了一种更准确的身份验证方法,尤其是在多个合约调用中。

在此之前

function transfer(address payable _to, uint _amount) public {
    // 脆弱的行:使用 tx.origin 进行身份验证
    require(tx.origin == owner, "不是所有者");

    (bool sent, ) = _to.call{value: _amount}("");
    require(sent, "发送以太失败");
}

在之后

function transfer(address payable _to, uint256 _amount) public {
    // 修复的行:使用 msg.sender 进行身份验证
    require(msg.sender == owner, "不是所有者");

    (bool sent, ) = _to.call{value: _amount}("");
    require(sent, "发送以太失败");
}

自毁(已弃用)

selfdestruct 是 Solidity 中提供的一个内置函数,现在已被弃用(在 0.8.18>)。这一弃用应当证明 Solidity 及其不断改进的有效性。它允许智能合约“被删除”,有效地将其账户存储和状态设置为 0x,同时将智能合约持有的任何 ETH 发送到函数调用中指定的接收者。

这种漏洞在 2017 年造成了超过 10 亿美元的损失,当时 Parity 库(一个多重签名钱包提供商)中的一个访问控制缺陷让攻击者拥有了库合约的所有权。在这种控制下,他们可以调用 selfdestruct 函数,并因此使资金无法访问(至今如此)。

前置攻击

前置攻击发生在某个实体试图通过发送他们的交易并提高 gas 费用,从而通过让他们的交易首先被挖掘而获取其他交易的经济价值(即,在受害者的交易之前)。前置攻击可以应用于 web3 中的不同类别。例如,它可以用于试图利用 DeFi 中的套利机会、前置NFT铸造机会,或者任何其他一个实体可以事先模拟的交易,并有机会利用并获利。

这不是对 Solidity 本身的漏洞,但在考虑构建安全智能合约时仍然值得考虑。要了解更多关于前置攻击及其在最大可提取价值(MEV)中的作用,请查看此指南:什么是 MEV(最大可提取价值),以及如何使用 QuickNode 保护你的交易

测试你的知识

在继续巩固你的开发者知识之前,先用下面的测验考考自己:

🧠知识检查

在智能合约的上下文中,什么是重新进入攻击?

一种攻击,合约状态被未经授权的矿工修改;一种递归函数调用模式,可能导致未经授权的行为或智能合约资金的损失;一种特定于加密交易的钓鱼攻击;一种涉及重复挖掘同一块的攻击。

其他资源和工具

最后想法

随着我们的总结,请记住智能合约开发的环境在不断演变。持续学习并保持对安全进展和 Solidity 更新的了解,对于成为一个有经验的 web3 开发者至关重要。

订阅我们的 新闻通讯,获取更多有关 Web3 和区块链的文章和指南。如果你有任何问题或需要进一步的协助,请随时加入我们的 Discord 服务器或通过下面的表单提供反馈。通过关注我们的 Twitter (@QuickNode) 和我们的 Telegram 公告频道 保持信息畅通和联系。

我们 ❤️ 反馈!

请告诉我们 如果你有任何反馈或新主题的请求。我们非常乐意听取你的意见。

  • 原文链接: quicknode.com/guides/eth...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。