TrustedVolumes 遭黑事件深度解析:670 万美元的 RFQ 授权灾难
2026/8/7 13:26:18 网站建设 项目流程

TrustedVolumes 遭黑事件深度解析:670 万美元的 RFQ 授权灾难

三个小漏洞如何连锁触发,掏空做市商全部库存


2026 年 5 月 7 日,TrustedVolumes, 一家在 1inch 的 RFQ(Request-for-Quote,询价)生态系统中运作的流动性提供者与解析器(Resolver), 其存放在 WETH、WBTC、USDT 及 USDC 的资产,约 670 万美元在一夕之间被全数提空。而这一切,仅发生在一笔精心策划的单一交易之中,攻击者利用了协议自定义 RFQ 代理合约中三个环环相扣的授权失效问题

这起事件最值得深究的地方,并非攻击手法有多么精密,而是其漏洞本身简直「朴实无华」。这不是什么零日漏洞攻击,也不是新颖的密码学破解,而是一场典型的存取控制失败、参数混淆与状态管理崩溃的教科书级案例。

让我一步步带你剖析到底发生什么事、为什么会发生,以及最重要的是如何确保你的合约不会重蹈覆辙


TrustedVolumes 原本应该如何运作?

TrustedVolumes 作为 1inch Fusion 中 RFQ 系统的做市商与解析器,其架构遵循一个非常直观的模式:

  • 一个库存金库(Inventory Vault)0x9bA0CF1588E1DFA905eC948F7FE5104dD40EDa31)持有协议的数字货币储备。
  • 该金库向 RFQ 代理合约授予了**无限量的 ERC-20 授权(Approval)**这在做市商领域虽常见,但极度危险。
  • RFQ 实作合约(Implementation)0x88eb28009351Fb414A5746F5d8CA91cdc02760d8)包含订单填充的逻辑。
  • RFQ 代理合约(Proxy)0xeEeEEe53033F7227d488ae83a27Bc9A9D5051756)作为公开的入口点。

在正常的 RFQ 流程中,做市商(Maker)会事先授权特定的签署者(Signer),吃单方(Taker)提交已签章的订单,代理合约验证签章是否在允许清单内、检查重放保护(Replay Protection),然后透过transferFrom从做市商的库存中执行原子交换(Atomic Swap)。

原先应该被保证的三道防线是:只有被授权的签署者能批准订单、每个签署过的订单只能被填充一次、代币的来源必须是经过验证的做市商自有库存

很遗憾,这三道防线在同一时间全面溃堤。


让一切化为泡影的三个漏洞

漏洞 1:无许可的签署者注册(存取控制失效)

registerAllowedOrderSigner(address signer, bool allowed)函数的设计初衷,是让做市商能够注册授权的订单签署者。然而问题在于,这个函数被设定为public,而且它的存取控制仅仅检查了msg.sender,完全没有检查呼叫者是否真的具备「做市商」权限

// 漏洞代码: function registerAllowedOrderSigner(address signer, bool allowed) public { _allowedSigners[msg.sender][signer] = allowed; // 任何人都能为「自己的地址」注册「任何签署者」 }

这代表任何一个人, 就是任何路人甲都能呼叫这个函数,并将任何 EOA 地址指定为自己的有效签署者。攻击者部署了一个攻击合约,随即呼叫registerAllowedOrderSigner(0xC3...9100, true),轻轻松松将自己的 EOA 变成了该攻击合约(作为做市商)的合法签署人。

这就像银行让任何人走进来,把自己加到「授权支票签署人」的名单上一样离谱。


漏洞 2:完全失灵的重放保护(状态管理失败)

协议试图透过salt(随机数)机制来记录哪些订单已经被填充过。但悲剧的是,读取写入填充状态时,所使用的储存位置(Storage Slot)竟然不一样:

// 读取填充状态(合约检查时): bool filled = _filledOrderSalts[order.salt]; // 从某个位置读取 // 写入填充状态(合约更新时): _filledOrderSalts[order.someOtherField] = true; // 写入了「完全不一样」的位置

结果就是:同一笔订单可以被无限次提交、无限次填充。重放保护机制看似编译通过,实际上完全在空转。每次攻击者提交相同的伪造订单时,合约都去检查一个「永远不会被更新」的储存槽,然后愉快地判定「这笔订单还没填充过」。

这就像门锁检查门有没有锁好时,却去盯着隔壁那扇门看。


漏洞 3:关键的参数错配(授权来源失效)

这是三个漏洞中最致命的一个,也是真正导致 670 万美元被盗的元凶。当一笔 RFQ 订单被填充时,order.inventory字段. 一个在调用数据(Calldata)中完全由攻击者控制的地址竟然直接被当作transferFromfrom参数传入

整个验证流程只证明了「攻击者的 EOA 是攻击者控制的接收合约的有效签署者」,但它完全没有证明order.inventory与这个签署者有任何关系。

// 漏洞代码: function fillOrder(Order memory order, bytes memory signature) public { // 检查:签署者是否为 msg.sender(吃单方)的授权签署者? require(_allowedSigners[msg.sender][recoveredSigner], "Not authorized"); // 执行:从 order.inventory 提取代币(完全由攻击者操控!) IERC20(token).transferFrom(order.inventory, recipient, amount); }

攻击者将inventory字段设为0x9bA0CF1588E1DFA905eC948F7FE5104dD40EDa31,正是 TrustedVolumes 做市商自己的托管地址,而该地址早就对 RFQ 代理授予了「无限代币使用权」。

这就像一个系统检查了你的身份证,确认你有权进入大楼,然后却让你从别人的钱包里拿钱走人,只因为你走了不同的门。


攻击行动全还原:一步一步拆解

以下是攻击者实际执行的流程:

步骤 1:部署攻击合约。
攻击者部署了辅助合约0xD4D5DB5EC65272B26F756712247281515F211E95

步骤 2:将自己注册为授权签署者。
辅助合约呼叫registerAllowedOrderSigner(0xC3...9100, true),将攻击者的 EOA 设定为该辅助合约(作为做市商)的有效签署者。严格来说这在技术上是「合法」的,因为攻击者只是在自己的地址下授权自己。

步骤 3:伪造签署过的订单。
攻击者使用已注册的签署者地址,签署了一份maker = 攻击合约的订单,轻松通过签章验证。至于其他参数如代币种类、金额等,完全可任意填写。

步骤 4:利用参数错配发动攻击。
在未签署的调用数据中,攻击者将from(即inventory)设为 TrustedVolumes 的解析器地址。由于fillOrder函数从未要求「签署时的 maker」必须等于「执行时的 from 地址」,因此攻击者的签章依然有效。

步骤 5:掏空金库。
合约顺利验证了针对攻击者自己地址的签章,然后「尽忠职守」地透过先前设立的无限授权,从 TrustedVolumes 的库存中把钱转走。

步骤 6:转换并藏匿赃款。
几小时内,所有被盗资产都被换成 ETH,并分散至多个钱包。其中一个地址(0x61e6301614178a2ca21bfa0fbb30aba06acc2d1c)收到了约 137 万美元的 WBTC 和 127 万美元的稳定币,随后被全数兑换成 1,222.12 颗 ETH。


事后发展:部分资金返还与混乱的「和解」

离奇的是,攻击者后来返还了 1,122 颗 ETH 给协议方,同时保留了约 200 万美元,作为某种「赏金」留存。这种模式: 部分退款、非正式谈判、以赏金形式和解, 在 DeFi 黑客事件中已逐渐成为令人不安的常态。

TrustedVolumes 最初曾祭出漏洞赏金,并表示愿意「就双方皆能接受的解决方案进行建设性沟通」。部分资金追回总比没有好,但这并不能掩盖根本问题:这些漏洞当初为什么会存在?


如何根治这个问题:正确的修正方案

修正方案 1:严格限制特权函数的存取权限

问题所在:registerAllowedOrderSigner被设为public,而它理应受到严格限制。

正确写法:

// 漏洞写法(不安全): function registerAllowedOrderSigner(address signer, bool allowed) public { _allowedSigners[msg.sender][signer] = allowed; } // 安全写法(修正后): mapping(address => bool) public isMaker; // 只有协议认可的做市商 function registerAllowedOrderSigner(address signer, bool allowed) external { require(isMaker[msg.sender], "Only registered makers can authorize signers"); _allowedSigners[msg.sender][signer] = allowed; }

为什么有效:确保只有协议认证过的做市商才能授权签署者,彻底封堵自行注册的漏洞。函数的可见性应预设为privateinternal,除非有强烈的商业逻辑要求它必须公开。


修正方案 2:将授权与执行环境强制绑定

问题所在:合约针对msg.sender检查授权,却从完全不同的order.inventory提取资金。

正确写法:

// 漏洞写法(不安全): function fillOrder(Order memory order, bytes memory signature) public { require(_allowedSigners[msg.sender][signer], "Not authorized"); IERC20(token).transferFrom(order.inventory, recipient, amount); } // 安全写法(修正后): function fillOrder(Order memory order, bytes memory signature) public { // 库存来源「必须」与授权签署者的地址是同一个 require(_allowedSigners[order.inventory][signer], "Signer not authorized for this inventory"); IERC20(token).transferFrom(order.inventory, recipient, amount); }

为什么有效:将授权检查与资金来源紧密耦合。传入transferFrom的规范做市商地址,必须与权限映射中验证的地址完全一致。只要「被检查授权的地址」与「被扣款的地址」出现任何偏差,都是致命的漏洞。


修正方案 3:正确实作重放保护机制

问题所在:合约读取与写入填充状态时,使用了不同的储存位置。

正确写法:

// 漏洞写法(不安全): mapping(bytes32 => bool) private _filledOrderSalts; function fillOrder(Order memory order, bytes memory signature) public { require(!_filledOrderSalts[order.salt], "Order already filled"); // ... 执行转账 ... _filledOrderSalts[order.someOtherField] = true; // 写错位置了! } // 安全写法(修正后): mapping(bytes32 => bool) private _filledOrderSalts; function fillOrder(Order memory order, bytes memory signature) public { require(!_filledOrderSalts[order.salt], "Order already filled"); // ... 执行转账 ... _filledOrderSalts[order.salt] = true; // 正确:读写使用同一个 Key }

为什么有效:确保检查与标记填充状态使用同一个储存键值(Key),让重放保护确实发挥作用。


修正方案 4:限制授权额度的曝险范围

问题所在:金库直接授予 RFQ 代理无限额度的代币使用权。

正确写法:

// 漏洞写法(不安全): IERC20(token).approve(proxyAddress, type(uint256).max); // 无上限! // 安全写法(修正后): // 使用单笔订单专属的授权,并设定有效期限 IERC20(token).approve(proxyAddress, orderAmount + buffer); // 或者采用「拉取(Pull)」机制,每笔订单都需独立明确授权

为什么有效:每笔订单或每项资产独立设定授权上限并加上时效限制,能戏剧性地缩小单一漏洞被利用时的爆炸半径。高价值做市商对代理合约授予无限授权,就像把火药库的钥匙挂在门口。


修正方案 5:拒绝语义模糊的订单结构

问题所在:订单中的makertakerreceiver字段可以各自为政,且无需透过单一签章对所有字段做出承诺。

正确写法:

// 漏洞写法(不安全): function fillOrder(Order memory order, bytes memory signature) public { // 只检查签章有效性,未检查上下文一致性 } // 安全写法(修正后): function fillOrder(Order memory order, bytes memory signature) public { // 签章必须「承诺」所有关键字段 bytes32 orderHash = keccak256(abi.encode( order.maker, order.taker, order.inventory, order.token, order.amount, order.salt )); require(recoverSigner(orderHash, signature) == order.maker, "Invalid signature"); // 接着验证执行上下文是否与签署时的意图相符 require(order.inventory == order.maker, "Inventory must match maker"); }

为什么有效:将所有相关地址绑定在同一个签署过的意图中。如果订单的字段可以未经完整签章承诺而各自偏离,那么合约就是运行在一个残缺的信任模型之上。


更深层的教训:这一切原本都可以避免

TrustedVolumes 遭黑事件一点也不「高深」。它是基础安全措施失灵的结果,任何合格的审计公司都应该能轻易抓出这些问题。事实上,在事发之前,市场上并未有任何公开的智能合约审计报告, 而这种基础的存取控制缺陷,任何标准审计流程都不该放过。

更令人遗憾的是,同一个攻击者曾在 2025 年 3 月利用 1inch Fusion V1 的漏洞,得手约 500 万美元。TrustedVolumes 显然未将该次业界震撼教训纳入自家系统的防护考量。

身为开发者,我们必须将以下几点刻进骨子里:

存取控制绝非选配功能。将函数标记为public虽然简化了权限管理(因为不用担心授权使用者无法呼叫),但这可能彻底摧毁安全防线。请预设使用privateinternal,除非有强烈且明确的业务需求。

参数混淆是沉默的杀手。在 RFQ 模型下,做市商授予授权的前提是「只有通过验证的交易对手方才能触发订单填充」。当合约对着 A 字段检查权限,却对着 B 字段执行扣款时,这个核心假设便彻底崩溃。

代理合约是巨大的攻击面。未经严格审查的代理合约,背后扛着高价值的做市商授权,这是一种结构性的风险。请用与核心协议同等严格的标准来审计代理合约。

无限授权极度危险。代理合约被攻破时造成的损失,与它持有的授权额度成正比。请务必设定上限,没有第二句话。


总结

TrustedVolumes 遭黑事件是一记沉重的警钟,它提醒我们在 DeFi 的世界里,最微小的实作瑕疵都可能引发灾难性的损失。三个漏洞, 每一个单独来看都极度简单串联在一起,就能从一个承载真实经济活动的协议中卷走数百万美元。

部分资金的追回并未改变核心现实:代码风险从来都不是抽象概念。即便是活跃运作中的协议,也可能因为一个小实作缺陷而酿成大祸。对开发者而言,教训更加锋利:签章验证、存取控制、代理逻辑与升级路径,都需要以最严苛的标准进行审查,因为攻击者只需要找到「一个」弱点。

审计你的合约。测试所有的边界案例。反复挑战你自己对「什么被授权、什么不被授权」的假设。永远、永远不要因为代码能编译通过,就想当然地认为它万无一失。


攻击交易哈希:0xc5c61b3ac39d854773b9dc34bd0cdbc8b5bbf75f18551802a0b5881fcb990513

漏洞合约地址:0x88eb28009351Fb414A5746F5d8CA91cdc02760d8

受害者合约(解析器):0x9bA0CF1588E1DFA905eC948F7FE5104dD40EDa31

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询