引言:本文由天玄链开源开发者提供,欢迎报名公益天玄链训练营
https://blockchain.163.com/trainingCamp
一、重入和竞态
重入和竞态在solidity 编程安全中会多次提及,历史上也造成了重大的损失。
1.1 问题分析
竞态的描述不严格,竞态是在并发编程中的概念。而以太坊智能合约是单线程运行的。不存在并发。它的问题来自于重入。
我们在实现智能合约无法做到可重入的,所以就要添加重入的保护。一般情况下,重入问题来自于fallback 或者 recevie 方法,因为以太坊转账是最常用的功能。
当然如果调用了其他不安全的合约一样有这个问题。
1.2 可重入保护
一般的解决方案,"检查-生效-交互",这个方法是现在常用的,也是推荐使用的。
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| function untrustedWithdraw(address recipient) ``public
{
``uint amountToWithdraw = userBalances[recipient];
``rewardsForA[recipient] = ``0``;
``if
(!(recipient.call.value(amountToWithdraw)())) { ``throw``; }
}
function untrustedGetFirstWithdrawalBonus(address recipient) ``public
{
``if
(claimedBonus[recipient]) { ``throw``; } ``// 检查
``claimedBonus[recipient] = ``true``;``//生效
``rewardsForA[recipient] += ``100``;
``untrustedWithdraw(recipient); ``// 交互
}
|
另外,既然它表现的像多线程并发。还有一种解决方案,就是加锁。openzeppelin 中 ReentrancyGuard合约中的nonReentrant
modifier也是阻止重入的一种方法。
但是需要注意的是,这个modifier 只能对单个方法起作用,需要在所有状态相关方法上添加才能防止交叉重入。
综上,"检查-生效-交互" 推荐使用来防止重入。
1.3 显示标示untrust
在调用不安全外部合约的方法时,需要将该方法标示为untrust。同时,调用该untrust 的方法也应该是untrust。
对待untrust 方法规范是,首先处理完内部状态,最后调用untrust 方法。向外部地址转账,显然也是调用了外部合约方法,属于untrust 调用
二、安全转账
在uniswap中有很多转账的范例,比如下面的代码,将常用的ERC20 转账,注意,在safeTransferETH 中,它使用了to.call 而不是 to.transfer,因为而.send 或者 .transfer 是发送固定gas,
solidity 升级后,一些操作码的gas 可能有变化。这就导致之前的fallback 方法可能会失败。对建立在uniswap上的生态造成影响。
但是直接使用.call 有很多风险,比如要校验返回值,再比如重入攻击问题。所以尽量使用成熟的范式,是增强代码安全的有效手段。这里可能引入的重入风险,我们后面再单独分析。
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| function safeTransfer(
``address token,
``address to,
``uint256 value
) internal {
``// bytes4(keccak256(bytes('transfer(address,uint256)')));
``(bool success, bytes memory data) = token.call(abi.encodeWithSelector(``0xa9059cbb``, to, value));
``require(
``success && (data.length == ``0
|| abi.decode(data, (bool))),
``'TransferHelper::safeTransfer: transfer failed'
``);
}
function safeTransferFrom(
``address token,
``address from,
``address to,
``uint256 value
) internal {
``// bytes4(keccak256(bytes('transferFrom(address,address,uint256)')));
``(bool success, bytes memory data) = token.call(abi.encodeWithSelector(``0x23b872dd``, from, to, value));
``require(
``success && (data.length == ``0
|| abi.decode(data, (bool))),
``'TransferHelper::transferFrom: transferFrom failed'
``);
}
function safeTransferETH(address to, uint256 value) internal {
``(bool success, ) = to.call{value: value}(``new
bytes(``0``));
``require(success, ``'TransferHelper::safeTransferETH: ETH transfer failed'``);
}
|
三、以太发送方法
3.1 常用方法
常用的两种方法,send, transfer
send与transfer对比简析
相同之处
(1)均是向address发送ETH(以Wei做单位)
(2)发送的同时传输2300gas(gas数量很少,只允许接收方合约执行最简单的操作)
不同之处
(1)send执行失败返回false,transfer执行失败则会throw。这也就意味着使用send时一定要判断是否执行成功。
推荐
(1)默认情况下最好使用transfer(因为内置了执行失败的处理)
安全推荐是transfer,相信大家都知道。那么为什么transfer 更好,在于它是更高级的方法,封装了校验返回值,并抛出异常。
3.2 引申一下
我想引申一点是,在我们写方法的时候,当校验时,应该使用require 而不是 if,使用if 使得方法没有完成必要逻辑,而交易成功。这其实有隐患的。
除非你非常清楚这个差别,并认为只有if 才能满足函数需求。
比如有一个编程模式做频率检查,模式设计推荐的是
|----------------------------------------------------------------------------------------------------------------|
| modifier enabledEvery(uint t) {
``if
(now >= enabledAt) {
``enabledAt = now + t;
``_;
``}
}
|
其实这个设计就有我上面说的问题,虽然达到了限制频率的目的,但是整个交易时成功的。正常使用就会有很多困惑,一旦被集成到其他合约,就会造成攻击的隐患。
建议是使用require,一旦失败,交易会revert,而且原因清晰。
|-----------------------------------------------------------------------------------------------------------------------------------|
| modifier enabledEvery(uint t) {
``require(now >= enabledAt,``"too freq, wait a minute"``);
``enabledAt = now + t;
``_;
|
3.3 转账成功的条件
如果对方是合约地址,那么它需要具备receive 方法,或者payable 的fallback 方法。如果两者都没有则转账失败。但是从另一面说,没有这两个方法不代表不能收取以太。
这还是要从概念上区分一下。
这就引申出两个问题,
3.3.1 合约为用户转账
要考虑到失败的可能。尽量使用pull 而不是push 方法。也就是让用用户发起withdraw收取以太,而不是合约dispatch。
3.3.2 合约需要收帐的。
即便没有设置payable 的fallback,和 receive 方法,你依然不能阻止绕过收款方法,直接转账给合约,使得合约balance 跟合约内账本对不上。
所以,业务逻辑要使用自己的账本。
3.3.3 非标准ERC20 的影响
业务逻辑建立在自己的账本上就够了吗?对于有些token 虽然符合ERC 标准,但是具体实现却又做了一些改动,比如有个项目做了转账收手续费的操作,
在转账过程中收取。也就是实际到账比交易执行的要少,如果只关注交易是否成功,然后记账是造成漏洞,所以这种情况,还需要去查balance 的变化。
以上是在转账过程需要注意的,不仅要合约记录账本,还需要核对balance,以保证业务逻辑没有漏洞。
当然如果业务确信没有此类问题。可以简化,比如只涉及USDT 的入账,那么可以不考虑3.3.3。
四、慎用tx.origin
永远不要 tx.origin 用于授权,另一个合约可以有一个方法来调用你的合约(例如,用户有一些资金)并且你的合约将授权该交易,因为你的地址位于tx.origin
.
contract MyContract {
address owner;
function MyContract() public {
owner = msg.sender;
}
function sendTo(address receiver, uint amount) public {
require(tx.origin == owner);
(bool success, ) = receiver.call.value(amount)("");
require(success);
}
}
contract AttackingContract {
MyContract myContract;
address attacker;
function AttackingContract(address myContractAddress) public {
myContract = MyContract(myContractAddress);
attacker = msg.sender;
}
function() public {
myContract.sendTo(attacker, msg.sender.balance);
}
}
这是最常见的错误。
如果我们用tx.origin 能用来干嘛?我们先引申一下。EOA 的概念。
4.1 如何判断一个账户是EOA 账户而不是contract。
一种做法是如下,很多业务是这么写的。但其实是有问题。某些情况会失效,比如constructor中。
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| /** @dev Modifier requiring sender to be EOA. This check could be bypassed by a malicious
``* contract via initcode, but it takes care of the user error we want to avoid.
``*/
modifier onlyEOA() {
``// Used to stop deposits from contracts (avoid accidentally lost tokens)
``require(!Address.isContract(msg.sender), ``"Account not EOA"``);
``_;
}
|
那么如何判断EOA 呢,这里就用到了tx.origin.
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ``modifier onlyEOA() {
``// Used to stop deposits from contracts (avoid accidentally lost tokens)
``require(tx.origin == msg.sender, ``"Account not EOA"``);
``_;
``}
|