常见合约漏洞攻击手段 ¶
约 1541 个字 99 行代码 预计阅读时间 6 分钟
Abstract
一些经典、常见的智能合约漏洞和攻击手段
整型溢出 ¶
uint 是 Solidity 中很常用的类型,但它是无符号整型,而且 solidity 0.8 之前没有溢出的检查,所以很容易造成溢出。
例如:Ethernaut > Token,题目中的 balances 记录是 mapping(address => uint),而且在 transfer 函数中没有没有检查溢出:
contract Token {
mapping(address => uint) balances;
uint public totalSupply;
constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}
这个问题的一个解决方法是判断溢出,可以使用 OpenZeppelin 的 SafeMath 库:
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
contract Token {
using SafeMath for uint256;
...
}
变长数组长度下溢任意写 ¶
整型溢出带来的一个更严重的问题是如果允许用户操作变长数组长度,则有可能导致数组长度下溢,从而可以读写全部的 storage。
例如:Ethernaut > Alien Codex,题目中提供了一个方法来减小数组长度,同时存在写入数组的函数:
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function make_contact() public {
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
重入攻击 ¶
重入攻击(Re-entrancy)是一个很经典的区块链智能合约攻击方式。
例如如下合约:
contract Bank {
mapping(address => uint256) balances;
...
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
msg.sender.call.value(amount)("");
balances[msg.sender] -= amount;
}
}
所以如果合约中有类似的操作的话,一定要先记账再转钱。或者可以使用 OpenZeppelin 的 ReentrancyGuard,里面提供了一个 modifier nonReentrant 可以防止重入。
伪随机数 ¶
由于区块链的特性,需要所有以太坊节点验证交易计算出相同结果达成共识,所以智能合约中并不能实现真随机数,而伪随机也有很多种可能来破解。
使用区块变量作为随机数 ¶
比如在验证的时候用到了当前区块的信息,如 coinbase、timestamp、number 等。而在我们进行交易时,是处在同一个区块的,所以可以同时计算出相同的随机数值。
例题:Ethernaut > Coin Flip、Security Innovation > Lottery、Security Innovation > Heads or Tails。
使用 blockhash 作为随机数 ¶
前面的几道例题中也有使用 blockhash 的情况,不过都是计算的当前区块的 hash。而这里要说的是另一种情况,也就是记录了某一时刻的 block number,然后在之后使用 blockhash 计算 hash。
这种情况的问题是,blockhash 函数只会计算最近 256 个块的 hash,更早的区块计算 blockhash 得到的都是 0。
例题:Security Innovation > Raffle。
回滚攻击 ¶
比如预测和支付在同一次交易里,也就是说,我给出一个预测,对方就会通过交易的金额来给我反馈正确与否。这样的话合约转来的金额肯定是不一样的,那么在接收的时候就可以在 fallback 函数中判断一下接收到的金额,如果不是正确的金额,就可以直接 revert 来回滚整条交易,然后继续猜测。
薅羊毛攻击 ¶
比如一个 Token 合约,实现了空投函数,也就是发放一定数量的初始 token 给用户,但是限制了每个用户只能领取一次:
contract Token is BasicToken {
mapping (address => bool) airdroped;
...
function airdrop() public {
require(!airdroped[msg.sender]);
airdroped[msg.sender] = true;
balances[msg.sender] += 100;
}
...
}
读取私有变量 ¶
这个漏洞就不必细说了,solidity 中的 private 变量只是没有公开查询的接口,实际上是可以直接读取对应 storage 的 slot 值来获取的。相关原理:以太坊基础 > 存储。
例题:Ethernaut > Vault、Ethernaut > Privacy、Security Innovation > Lock Box。
远程调用 ¶
一个比较经典、不容易发现的漏洞,就是合约中大胆地使用了 delegatecall 来进行跨合约函数调用。以太坊提供的四种远程调用方法的区别在以太坊虚拟机 > ETH 字节码 > 远程调用指令中介绍过:
- call:执行环境为被调用者的环境,且 msg 会修改为调用者
- callcode:执行环境为调用者自己的环境,且 msg 会修改为调用者(一般不用)
- delegatecall:执行环境为被调用者的环境,且 msg 不会修改(也就相当于将其它合约的字节码拿到自己身上来执行)
- staticcall:不允许修改状态(这里不需要关心)
所以一个很显然的事情就是如果使用了 delegatecall 来调用外部给的合约地址中的函数,那么就完全有权限去修改这个合约中的状态。
contract Preservation {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint storedTime;
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
contract LibraryContract {
uint storedTime;
function setTime(uint _time) public {
storedTime = _time;
}
}
Ethernaut > Puzzle Wallet 同理,也是一道没有注意 delegatecall 导致 slot 混乱的例题。
创建日期: 2022年11月28日 23:33:01