0%

《Solidity 智能合约教程》Solidity 安全与建议

安全

随机数

在智能合约中使用时间或者区块号等信息确实可以做到一定的随机,但是这是伪随机

矿工可以比较轻松的知道下一次的随机数值,所以这是非常不安全的

重入

重入是指,合约 A 在方法 methodA 中调用合约 B 的方法 methodB ,methodA 还没有执行完,methodB 又调用了 methodA ,methodA 遭到了重入

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
if (payable(msg.sender).send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}

如果 msg.sender 是一个合约,而且具有 receive 方法或者 fallback 方法,而且在 receive 方法或者 fallback 方法中调用了 Fund 合约的 withdraw 方法

则发生了重入,由于这里是使用 send 发送 eth ,问题不是很严重(因为 send 只能转发 2300 个 gas,receive 方法或者 fallback 方法会因为 gas 不够而失败)

但如果是下面这样,则问题就严重了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
(bool success,) = msg.sender.call{value: shares[msg.sender]}("");
if (success)
shares[msg.sender] = 0;
}
}

Fund 合约中所有的 eth 都会被洗劫一空

解决这个问题,可以有以下办法:

  1. 先改变,再去调用,调用失败就回滚
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
uint share = shares[msg.sender];
shares[msg.sender] = 0;
payable(msg.sender).transfer(share); // 或者下面这句
// require(payable(msg.sender).send(share), "send error");
}
}
  1. call 改成 transfer 或 send
  2. 使用重入锁避免函数递归重入

重入锁:

1
2
3
4
5
6
modifier nonReentrant() {
_guardCounter += 1;
uint256 localCounter = _guardCounter;
_;
require(localCounter == _guardCounter, "ReentrancyGuard: reentrant call");
}

循环与 gas limit

如果在合约的写函数中,你写了一个 for 循环,那么请确保迭代次数不会太大

因为以太坊中,每一个块都有能消耗的 gas 上限,当然每笔交易也就有 gas 消耗上限

如果你的 for 循环中的迭代次数太大,gas 不够消耗,则会导致交易永远不会成功,即使你给了足够多的 gas

view 或 pure 只读函数没有这个限制,因为他们不消耗 gas

tx.origin

tx.origin 是指这笔交易的发送者

不要使用这个去做认证,否则会有安全隐患,看下面例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
address owner;

constructor() {
owner = msg.sender;
}

function transferTo(address payable dest, uint amount) public {
require(tx.origin == owner);
dest.transfer(amount);
}
}

上面使用了 require(tx.origin == owner) 进行安全认证,相当于一把钥匙掌握在交易发送者手里,只要交易发送者调用(注意,这里不是指亲自调用,间接调用也算)这个函数,就可以转出 eth

其实如果 owner 地址不调用除了这个合约以外的任何合约,这是没有问题的,但是假如某个恶意合约引诱你去调用它,那么很可能你的以太坊就都被转走了

因为你调用恶意合约,恶意合约再调用你的合约,就相当于你把钥匙交给了这个恶意合约,恶意合约拿着你的钥匙就可以转走以太坊

下面是恶意合约的示例,只要你往这个合约发送 eth,你合约中的 eth 就会被转走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface TxUserWallet {
function transferTo(address payable dest, uint amount) external;
}

contract TxAttackWallet {
address payable owner;

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

receive() external payable {
TxUserWallet(<你的合约的地址>).transferTo(owner, <你的合约地址的以太坊余额>);
}
}

建议

  • 严格处理编译器报出的警告

  • 总是使用最新版本的编译器

  • 合约中多一些自检代码。参考 SafeMath(v8.0.0 之后的版本已经不需要 SafeMath)

  • review 代码

  • 尽量不要存币到智能合约,除非你保证合约没有问题,且可以取出币

  • 严格限制变量访问权限。最小权限原则

  • 编写的函数中有任何不符合预期的结果,都应当使用 require 直接抛出异常,而不要使用 (result, error) 的返回值模式。如果调用者需要根据错误做相应的动作,则可以使用 try/catch 捕捉异常

  • 严格判断调用的返回值。比如 send 函数发生错误时不会抛出异常,而是返回 false,这里一定要校验返回值

  • 如果采用 Proxy 可升级模式编程时,应当将所有的存储变量放到一个单独的 Contract 中,方便后续升级需要增加存储变量时可以方便的在最后添加(如果在中间添加会造成数据错乱。因为一个萝卜一个坑,而坑发生了错位)

视频教程地址

敬请期待

pefish 学习社区

微信:pefishpefish,加好友,备注pefish 学习社区,邀你入群

Telegramhttps://t.me/pefish_study




微信关注我,及时接收最新技术文章