前言
前面已经学习了 ERC20 是什么,也学习了怎么使用 OpenZeppelin 来实现一个更接近生产项目的 ERC20。
下面继续学习合约安全基础,因为在Web3中智能合约和我们传统Web2后端最大的区别之一就是:后端服务出 bug,可以停服、修数据、回滚、补偿(中心化);但是合约一旦部署到链上,很多状态已经公开写死了,也不好修改(去中心化),同时Web3合约大部分都跟钱有关,因此出现安全事故,损失的也是钱。
所以能跑不代表安全,安全在Web3是最要的事情了
这篇主要学习:
- payable
- receive
- fallback
- ETH 转账
- call
- 重入攻击
- CEI 模式
- OpenZeppelin ReentrancyGuard
- OpenZeppelin Pausable
为什么学完 ERC20 要先学安全
ERC20 更多是在学习标准接口。
比如:
1 | balanceOf |
但是实际项目中,会经常遇到这些场景:
- 用户往合约里存储/提取 ETH
- 合约调用外部合约
- 合约给用户转账
- 外部合约回调当前合约
- 多个状态变量必须保持一致
ETH 转账和外部调用,很容易引出重入攻击。
payable关键字 是什么
Solidity 里,函数默认不能接收 ETH。
如果一个函数想接收 ETH,就必须加 payable。
比如:
1 | function deposit() external payable { |
调用这个函数时,用户可以附带 ETH。
在函数里可以通过:
1 | msg.value |
拿到这次调用附带了多少 wei。
注意单位是 wei,不是 ETH。
1 | 1 ETH = 10^18 wei |
所以如果 msg.value = 1000000000000000000,表示用户转了 1 ETH。
receive函数 是什么
receive() 是一个特殊函数。
当合约收到一笔 calldata 为空的调用时,会触发它。
最常见的场景就是别人直接给合约地址转 ETH。
写法如下:
1 | receive() external payable { |
几个特点:
- 一个合约最多只能有一个
receive - 必须是
external - 必须是
payable - 不能有参数
- 不能有返回值
比如你在 Remix 里直接往合约地址转 ETH,如果没有指定调用哪个函数,一般就会走 receive()。
严格说,即使 msg.value 是 0,只要 calldata 为空,也可以触发 receive()。
fallback函数 是什么
fallback() 也是特殊函数。
当调用了不存在的函数,或者 calldata 不为空但没有匹配到函数时,会触发它。
写法如下:
1 | fallback() external payable { |
简单理解:
1 | receive 负责普通收 ETH |
不过生产项目里,不建议在 fallback 里写复杂逻辑。
如果项目不打算支持未知调用,直接 revert 更清晰。
receive 和 fallback 的触发关系
可以简单记成下面这个判断:
1 | 收到 ETH 或调用合约 |
平常我们使用receive接受ETH就可以了
1 | receive() external payable {} |
怎么给别人转 ETH
以前经常能看到三种写法:
1 | to.transfer(amount); |
但现在更推荐使用 call,原因是 transfer 和 send 只转发 2300 gas,这个限制在一些 EVM gas 成本变化后不够灵活,可能导致接收方合约无法正常收款。
现在常见写法是:
1 | (bool success, ) = payable(to).call{value: amount}(""); |
但是 call 有一个非常重要的问题:
1 | call 会把控制权交给接收方。 |
如果接收方是一个恶意合约,它可以在收到 ETH 的时候,反过来再次调用你的合约。
这就是重入攻击的基础。
什么是重入攻击
重入攻击可以理解成:
1 | 你的合约还没处理完,外部合约又重新进来调用你。 |
例如下面这个不安全的提款逻辑:
- 先转钱
- 后清空余额
1 | function withdrawAll() external { |
如果 msg.sender 是恶意合约,它收到 ETH 后,在自己的 receive() 里再次调用 withdrawAll()。
由于你的合约还没来得及把余额清零,所以第二次进来时:balanceOf[msg.sender] > 0 还是成立的,于是可以被重复执行,把合约的钱都提走,这是最经典的重入攻击。
CEI 模式
CEI 是智能合约里非常重要的安全模式,全称:checks(先检查) effects(改状态) interactions(在调用)。
所以前面的合约应该改成如下安全一点的写法:
1 | function withdrawAll() external { |
这里先扣余额,再转 ETH,如果恶意合约在收到 ETH 后再次调用 withdrawAll(),它余额已经被清零了,就无法重复提款。
所以 CEI 的核心思想就是:不要把外部调用放在状态更新之前。
ReentrancyGuard 是什么
OpenZeppelin 提供了 ReentrancyGuard。
它的作用是给函数加一把锁,防止同一个函数在执行过程中被再次进入,包引入如下:
1 | import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; |
使用方式:
1 | contract MyContract is ReentrancyGuard { |
nonReentrant 的基本思路是:
1 | 函数开始执行时,把状态标记为 entered |
通常实际项目可以:用CEI思想 和 ReentrancyGuard修饰符一起防止重入攻击。
Pausable 是什么
OpenZeppelin 还提供了 Pausable。它是一个紧急暂停模块,包导入如下:
1 | import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; |
常见用法:
1 | function deposit() external payable whenNotPaused { |
暂停后,带 whenNotPaused 的函数会失败,这个机制常用于发现系统出现漏洞或者异常情况,先冻结止损
实战合约:安全 ETH 金库
下面写一个简单的 ETH Vault。
功能:
- 用户可以 deposit ETH
- 用户可以 withdraw ETH
- 使用 CEI 防重入
- 使用 OpenZeppelin ReentrancyGuard
- 使用 OpenZeppelin Pausable
- 使用 AccessControl 管理 pause 权限
代码如下:
1 | // SPDX-License-Identifier: MIT |
代码拆解
creditOf
这个就是用户在金库里的余额表。用户 deposit 时增加,withdraw 时减少。
1 | mapping(address => uint256) public creditOf; |
receive
1 | receive() external payable { |
这个表示用户直接给合约地址转 ETH 时,也算作 deposit。
fallback
1 | fallback() external payable { |
这里直接不支持未知函数调用。
withdraw
这里用了 CEI思想和nonReentrant 来防止重入攻击。
- 检查余额
- 扣掉余额
- 转出 ETH
1 | function withdraw(uint256 amount) external nonReentrant { |
pause 和 unpause
1 | function pause() external onlyRole(PAUSER_ROLE) { |
这里只有 PAUSER_ROLE 可以暂停和恢复。
注意这里我们只让 deposit 受 pause 影响:
1 | function _deposit(address account, uint256 amount) private whenNotPaused |
这里查阅了一下,withdraw操作一般不加 whenNotPaused,因为不让用户提款会引起恐慌的。
默认思路是:能让用户安全退出,就尽量让用户安全退出。
交易日志
deposit 成功后,会有事件:
1 | event Deposited(address indexed account, uint256 amount); |
日志里可以看到:
1 | account = 你的地址 |
withdraw 成功后,会有事件:
1 | event Withdrawn(address indexed account, uint256 amount); |
链下系统可以监听这些事件。
比如后端索引器可以根据事件构建用户充值和提款记录。
这和之前 ERC20 的 Transfer、Approval 是一个思路。
几个安全习惯
1. 不要用 tx.origin 做权限判断
1 | require(tx.origin == owner, "not owner"); // 错误,危险 |
tx.origin = 这笔交易最开始是谁用钱包发起的
msg.sender = 当前这一次调用是谁直接调用我的
tx.origin 是整笔交易最开始的 EOA。
如果用户被诱导调用恶意合约,恶意合约再调用你的合约,tx.origin 仍然是用户地址。
所以用 tx.origin 做权限判断很危险。
2. 外部调用放最后
只要看到如下几个函数,都要意思到可能发生外部调用,所以要仔细检查状态是否更新,是否可能被重入
1 | call |
3. 不要遍历大量用户
链上循环有 gas 限制。
比如不要写:
1 | for (uint256 i = 0; i < users.length; i++) { |
这种批量 push payment 很容易因为某个地址失败,或者 gas 不够,导致整个交易失败。
更好的模式是:
1 | 记录每个人能提多少 |
这就是 pull payment 思路。
面试题整理
receive 和 fallback 有什么区别?
receive() 在 calldata 为空时触发,最常见场景是合约直接收到 ETH。
fallback() 在调用不存在函数,或者没有 receive 可以处理时触发。
为什么现在更推荐 call 转 ETH?
因为 transfer 和 send 只转发 2300 gas,灵活性不够,可能因为 gas 成本变化导致接收方合约无法收款。
call{value: amount}("") 更灵活,但要注意重入风险。
什么是重入攻击?
合约外部调用时,把控制权交给了外部合约。
外部合约在你的函数执行完之前,又反过来调用你的合约,利用你还没更新完成的状态重复操作。
CEI 是什么?
Checks, Effects, Interactions。
先检查条件,再更新状态,最后进行外部调用。
ReentrancyGuard 的作用是什么?
给函数加一把执行锁。
函数执行过程中,如果再次进入带 nonReentrant 的函数,会直接 revert。
pause 后应该允许 withdraw 吗?
一般情况下,建议允许用户安全退出。
但是具体要看业务风险,如果攻击路径就在 withdraw,也可能需要更严格的暂停策略。
小结
这篇主要学习了 Solidity 合约安全基础,总结一下就是下面几句话:
1 | payable 才能收 ETH |
参考
- Solidity 官方安全注意事项:https://docs.soliditylang.org/en/latest/security-considerations.html
- Solidity receive/fallback 说明:https://docs.soliditylang.org/en/latest/contracts.html#receive-ether-function
- OpenZeppelin Contracts Utils:https://docs.openzeppelin.com/contracts/5.x/api/utils