抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

前言

前面已经学习了 ERC20 是什么,也学习了怎么使用 OpenZeppelin 来实现一个更接近生产项目的 ERC20。

下面继续学习合约安全基础,因为在Web3中智能合约和我们传统Web2后端最大的区别之一就是:后端服务出 bug,可以停服、修数据、回滚、补偿(中心化);但是合约一旦部署到链上,很多状态已经公开写死了,也不好修改(去中心化),同时Web3合约大部分都跟钱有关,因此出现安全事故,损失的也是钱。

所以能跑不代表安全,安全在Web3是最要的事情了

1751cca4-ad00-4c45-a097-bf43a13f34f4

这篇主要学习:

  • payable
  • receive
  • fallback
  • ETH 转账
  • call
  • 重入攻击
  • CEI 模式
  • OpenZeppelin ReentrancyGuard
  • OpenZeppelin Pausable

为什么学完 ERC20 要先学安全

ERC20 更多是在学习标准接口。

比如:

1
2
3
4
5
balanceOf
transfer
approve
transferFrom
allowance

但是实际项目中,会经常遇到这些场景:

  • 用户往合约里存储/提取 ETH
  • 合约调用外部合约
  • 合约给用户转账
  • 外部合约回调当前合约
  • 多个状态变量必须保持一致

ETH 转账和外部调用,很容易引出重入攻击。

payable关键字 是什么

Solidity 里,函数默认不能接收 ETH。

如果一个函数想接收 ETH,就必须加 payable

比如:

1
2
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
2
receive() external payable {
}

几个特点:

  • 一个合约最多只能有一个 receive
  • 必须是 external
  • 必须是 payable
  • 不能有参数
  • 不能有返回值

比如你在 Remix 里直接往合约地址转 ETH,如果没有指定调用哪个函数,一般就会走 receive()

严格说,即使 msg.value 是 0,只要 calldata 为空,也可以触发 receive()

fallback函数 是什么

fallback() 也是特殊函数。

当调用了不存在的函数,或者 calldata 不为空但没有匹配到函数时,会触发它。

写法如下:

1
2
fallback() external payable {
}

简单理解:

1
2
receive 负责普通收 ETH
fallback 负责兜底处理未知调用

不过生产项目里,不建议在 fallback 里写复杂逻辑。

如果项目不打算支持未知调用,直接 revert 更清晰。

receive 和 fallback 的触发关系

可以简单记成下面这个判断:

1
2
3
4
5
6
7
收到 ETH 或调用合约
|
|-- calldata 为空,并且存在 receive()
| -> 调 receive()
|
|-- calldata 不为空,或者没有 receive()
-> 调 fallback()

平常我们使用receive接受ETH就可以了

1
2
3
4
5
6
receive() external payable {}


fallback() external payable {
revert("unsupported call"); // 如果要拦截错误
}

怎么给别人转 ETH

以前经常能看到三种写法:

1
2
3
to.transfer(amount);
to.send(amount);
to.call{value: amount}("");

但现在更推荐使用 call,原因是 transfersend 只转发 2300 gas,这个限制在一些 EVM gas 成本变化后不够灵活,可能导致接收方合约无法正常收款。

现在常见写法是:

1
2
(bool success, ) = payable(to).call{value: amount}("");
require(success, "ETH transfer failed");

但是 call 有一个非常重要的问题:

1
call 会把控制权交给接收方。

如果接收方是一个恶意合约,它可以在收到 ETH 的时候,反过来再次调用你的合约。

这就是重入攻击的基础。

什么是重入攻击

重入攻击可以理解成:

1
你的合约还没处理完,外部合约又重新进来调用你。

例如下面这个不安全的提款逻辑:

  1. 先转钱
  2. 后清空余额
1
2
3
4
5
6
7
8
9
function withdrawAll() external {
uint256 amount = balanceOf[msg.sender];
require(amount > 0, "nothing to withdraw");

(bool success, ) = msg.sender.call{value: amount}(""); // 调用了外部合约,外部合约有调用了withdrawAll方法
require(success, "transfer failed");

balanceOf[msg.sender] = 0;
}

如果 msg.sender 是恶意合约,它收到 ETH 后,在自己的 receive() 里再次调用 withdrawAll()

由于你的合约还没来得及把余额清零,所以第二次进来时:balanceOf[msg.sender] > 0 还是成立的,于是可以被重复执行,把合约的钱都提走,这是最经典的重入攻击。

CEI 模式

CEI 是智能合约里非常重要的安全模式,全称:checks(先检查) effects(改状态) interactions(在调用)。

所以前面的合约应该改成如下安全一点的写法:

1
2
3
4
5
6
7
8
9
function withdrawAll() external {
uint256 amount = balanceOf[msg.sender];
require(amount > 0, "nothing to withdraw");

balanceOf[msg.sender] = 0;

(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}

这里先扣余额,再转 ETH,如果恶意合约在收到 ETH 后再次调用 withdrawAll(),它余额已经被清零了,就无法重复提款。

所以 CEI 的核心思想就是:不要把外部调用放在状态更新之前。

ReentrancyGuard 是什么

OpenZeppelin 提供了 ReentrancyGuard

它的作用是给函数加一把锁,防止同一个函数在执行过程中被再次进入,包引入如下:

1
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

使用方式:

1
2
3
4
5
contract MyContract is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
...
}
}

nonReentrant 的基本思路是:

1
2
3
函数开始执行时,把状态标记为 entered
函数执行结束后,恢复为 not entered
如果执行过程中再次进入,就直接 revert

通常实际项目可以:用CEI思想 和 ReentrancyGuard修饰符一起防止重入攻击。

Pausable 是什么

OpenZeppelin 还提供了 Pausable。它是一个紧急暂停模块,包导入如下:

1
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";

常见用法:

1
2
3
function deposit() external payable whenNotPaused {
...
}

暂停后,带 whenNotPaused 的函数会失败,这个机制常用于发现系统出现漏洞或者异常情况,先冻结止损

实战合约:安全 ETH 金库

下面写一个简单的 ETH Vault。

功能:

  • 用户可以 deposit ETH
  • 用户可以 withdraw ETH
  • 使用 CEI 防重入
  • 使用 OpenZeppelin ReentrancyGuard
  • 使用 OpenZeppelin Pausable
  • 使用 AccessControl 管理 pause 权限

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureEthVault is AccessControl, Pausable, ReentrancyGuard {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

mapping(address => uint256) public creditOf;
uint256 public totalCredits;

error ZeroAddress();
error ZeroAmount();
error UnsupportedCall();
error InsufficientCredit(address account, uint256 balance, uint256 requested);
error EthTransferFailed(address to, uint256 amount);

event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);

constructor(address admin) {
if (admin == address(0)) {
revert ZeroAddress();
}

_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
}

receive() external payable {
_deposit(msg.sender, msg.value);
}

fallback() external payable {
revert UnsupportedCall();
}

function deposit() external payable {
_deposit(msg.sender, msg.value);
}

function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) {
revert ZeroAmount();
}

uint256 balance = creditOf[msg.sender];
if (balance < amount) {
revert InsufficientCredit(msg.sender, balance, amount);
}

creditOf[msg.sender] = balance - amount;
totalCredits -= amount;

(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
revert EthTransferFailed(msg.sender, amount);
}

emit Withdrawn(msg.sender, amount);
}

function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}

function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}

function _deposit(address account, uint256 amount) private whenNotPaused {
if (amount == 0) {
revert ZeroAmount();
}

creditOf[account] += amount;
totalCredits += amount;

emit Deposited(account, amount);
}
}

代码拆解

creditOf

这个就是用户在金库里的余额表。用户 deposit 时增加,withdraw 时减少。

1
mapping(address => uint256) public creditOf;

receive

1
2
3
receive() external payable {
_deposit(msg.sender, msg.value);
}

这个表示用户直接给合约地址转 ETH 时,也算作 deposit。

fallback

1
2
3
fallback() external payable {
revert UnsupportedCall();
}

这里直接不支持未知函数调用。

withdraw

这里用了 CEI思想和nonReentrant 来防止重入攻击。

  1. 检查余额
  2. 扣掉余额
  3. 转出 ETH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) {
revert ZeroAmount();
}

uint256 balance = creditOf[msg.sender];
if (balance < amount) {
revert InsufficientCredit(msg.sender, balance, amount);
}
creditOf[msg.sender] = balance - amount;
totalCredits -= amount;
(bool success, ) = payable(msg.sender).call{value: amount}("");
...

}

pause 和 unpause

1
2
3
4
5
6
7
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}

function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}

这里只有 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
2
account = 你的地址
amount = 存入金额

withdraw 成功后,会有事件:

1
event Withdrawn(address indexed account, uint256 amount);

链下系统可以监听这些事件。

比如后端索引器可以根据事件构建用户充值和提款记录。

这和之前 ERC20 的 TransferApproval 是一个思路。

几个安全习惯

1. 不要用 tx.origin 做权限判断

1
2
require(tx.origin == owner, "not owner"); // 错误,危险
require(msg.sender == owner, "not owner"); // 推荐

tx.origin = 这笔交易最开始是谁用钱包发起的
msg.sender = 当前这一次调用是谁直接调用我的

tx.origin 是整笔交易最开始的 EOA。

如果用户被诱导调用恶意合约,恶意合约再调用你的合约,tx.origin 仍然是用户地址。

所以用 tx.origin 做权限判断很危险。

2. 外部调用放最后

只要看到如下几个函数,都要意思到可能发生外部调用,所以要仔细检查状态是否更新,是否可能被重入

1
2
3
4
5
call
transferFrom
safeTransfer
onERC721Received
onERC1155Received

3. 不要遍历大量用户

链上循环有 gas 限制。

比如不要写:

1
2
3
for (uint256 i = 0; i < users.length; i++) {
payable(users[i]).call{value: amount}("");
}

这种批量 push payment 很容易因为某个地址失败,或者 gas 不够,导致整个交易失败。

更好的模式是:

1
2
记录每个人能提多少
让用户自己 withdraw

这就是 pull payment 思路。

面试题整理

receive 和 fallback 有什么区别?

receive() 在 calldata 为空时触发,最常见场景是合约直接收到 ETH。

fallback() 在调用不存在函数,或者没有 receive 可以处理时触发。

为什么现在更推荐 call 转 ETH?

因为 transfersend 只转发 2300 gas,灵活性不够,可能因为 gas 成本变化导致接收方合约无法收款。

call{value: amount}("") 更灵活,但要注意重入风险。

什么是重入攻击?

合约外部调用时,把控制权交给了外部合约。

外部合约在你的函数执行完之前,又反过来调用你的合约,利用你还没更新完成的状态重复操作。

CEI 是什么?

Checks, Effects, Interactions。

先检查条件,再更新状态,最后进行外部调用。

ReentrancyGuard 的作用是什么?

给函数加一把执行锁。

函数执行过程中,如果再次进入带 nonReentrant 的函数,会直接 revert。

pause 后应该允许 withdraw 吗?

一般情况下,建议允许用户安全退出。

但是具体要看业务风险,如果攻击路径就在 withdraw,也可能需要更严格的暂停策略。

小结

这篇主要学习了 Solidity 合约安全基础,总结一下就是下面几句话:

1
2
3
4
5
6
7
8
payable 才能收 ETH
receive 处理普通收 ETH
fallback 处理未知调用
call 是现在常见的 ETH 转账方式
call 会带来重入风险
CEI 是基本安全习惯
ReentrancyGuard 是额外保险
Pausable 是紧急暂停机制

参考