2026 年 8 月 19 日,跨链桥 Allbridge 的 Base 侧 CCTP Router 被攻击,损失约 19 万美元。这次事故的特别之处在于:攻击者手里那条跨链消息,是 Circle 亲自签名认证过的合法消息——只不过它对应的 100 万 USDC 从来没有被销毁过。
事故速览
- 事发时间 2026-08-19,铺垫时间 2026-07-26,前后跨度约 24 天
- 涉及链:Polygon(伪造消息发出端)→ Base(Router 被打端)
- 直接损失 约 189,751 USDC(约 19 万美元)
- 攻击类型:跨链消息校验缺失 + 闪电贷凑余额
- 关键教训:Circle 的 attestation 只证明「这条消息真的被发出过」,不证明「源链真的销毁了 USDC」
注意区分:2026 年 7 月 19 日 Allbridge 还发生过另一起约 165 万美元的 Solana 侧闪电贷事故(同一个 Pool 账户被同时当作 send 和 receive 传入),那是完全不同的问题,本文不涉及。
背景:CCTP 到底证明了什么
Circle 的 CCTP(Cross-Chain Transfer Protocol)是「销毁—铸造」模型的跨链方案,正常流程是:
- 用户在源链调用 Circle 的
TokenMessengerV2.depositForBurn(),合约销毁用户的 USDC; TokenMessengerV2内部再调用MessageTransmitterV2.sendMessage(),把一条描述这笔转账的消息广播出去,产生MessageSent事件;- Circle 的链下 Attestation 服务看到这个事件,对消息体签名,产出 attestation;
- 有人拿着
(message, attestation)在目标链调用MessageTransmitterV2.receiveMessage(),验签通过后回调TokenMessengerV2,铸造等量 USDC 给接收方。
问题就藏在第 2 步和第 3 步之间的一个事实里:
MessageTransmitterV2.sendMessage() 是一个通用消息通道,任何地址都可以直接调用它。
也就是说,攻击者完全可以跳过第 1 步(不销毁任何 USDC),直接调 sendMessage() 发一条「长得像 CCTP 转账」的消息。Circle 的签名服务按标准流程照样会给它出 attestation——因为从 Circle 的角度看,这条消息确实由 MessageTransmitterV2 发出过,签名是对「消息存在性」的证明,不是对「资产已销毁」的证明。
真正把这两件事绑定在一起的,是消息头里的 sender 字段:
- 正常路径:消息由
TokenMessengerV2代为发出,sender等于源链官方TokenMessengerV2地址; - 伪造路径:消息由攻击者自己发出,
sender等于攻击者的地址。
只要下游认真校验 sender,伪造消息就没有立足之地。Allbridge 恰恰漏掉了这一步。
攻击时间线
-
2026-07-26 · Polygon
埋雷:直接调用 MessageTransmitterV2.sendMessage()
攻击者构造一条 CCTP 格式的消息:source domain 为 Polygon,destination domain 为 Base,声明金额 1,000,000 USDC,
feeExecuted = 0,destinationCaller指向 Allbridge 的CCTPTokenMessenger,接收方指向自己的攻击合约,并把预先算好的messageHash塞进hookData。全程没有任何 USDC 被销毁。 - 2026-07-26 起 · 链下 Circle 正常签发 attestation 对 Circle 来说这就是一条格式合法、确实由 MessageTransmitter 发出的消息,于是按标准流程出具了有效签名。攻击者拿到了一张「官方认证的空头支票」。
- 约 24 天 · 等待 等 Router 余额够大 伪造的凭据是 100 万 USDC,但真正能提走的上限受 Router 实际持有的 USDC 约束。攻击者要么等大额入账,要么自己补齐差额——他选择了两者一起用。
- 2026-08-19 · Base 在一笔真实入账后的第 6 秒发动 Allbridge 的 relayer 刚为一笔合法的 CCTP 存款向 Router 铸入约 191,156 USDC,攻击者在 6 秒后紧跟着发出攻击交易。
-
2026-08-19 · Base
receiveCctpMessage:记账 100 万 USDC
攻击合约带着 7 月伪造的 message 与有效 attestation 调用 Allbridge 的
receiveCctpMessage()。由于缺少校验,合约认定这是一笔真实到账,为对应的messageHash写下一条 1,000,000 USDC 的可赎回记录。 - 2026-08-19 · Base Aave 闪电贷补齐差额 借入 808,844 USDC 打进 Router,把余额凑到刚好 1,000,000 USDC,让「账面记录」和「实际余额」在这一瞬间对得上。
-
2026-08-19 · Base
receiveToken:提走 999,000 USDC
Router 的提现逻辑只检查
CCTPTokenMessenger里这个messageHash对应的receivedTokenAmount是否大于零,校验通过,扣掉 0.1% 手续费后放行 999,000 USDC。 - 2026-08-19 · Base 还贷离场 偿还本金 808,844 USDC 加约 404.42 USDC 手续费,净获利约 189,751.55 USDC。
一笔账算下来是这样的:
| 步骤 | Router USDC 余额变化 | 攻击者收支 |
|---|---|---|
| relayer 合法铸币 | 约 191,156 | — |
| 伪造消息记账 | 不变 | 账面 +1,000,000(凭空) |
| Aave 闪电贷注入 | 1,000,000 | −808,844(负债) |
| receiveToken 提现 | 约 1,000 | +999,000 |
| 偿还闪电贷 | 不变 | −808,844 − 404.42 |
| 净额 | 约 −190,156 | 约 +189,751.55 |
Router 少掉的这 19 万,正是那笔属于真实用户的合法入账。
漏洞剖析:三道本该存在的关卡
根据 SlowMist 的分析,Allbridge 在这条链路上连续漏掉了三处校验。下面是按公开分析还原的简化伪代码,用于说明控制流,并非逐字源码:
// CCTPTokenMessenger(Base 侧)—— 攻击发生时的逻辑
function receiveCctpMessage(bytes calldata message, bytes calldata attestation) external {
// 1) 让 Circle 的 MessageTransmitter 验签。这一步是真的,也确实通过了。
// 但它证明的只是「这条消息由 MessageTransmitter 发出且被 Circle 签名」。
IMessageTransmitterV2(messageTransmitter).receiveMessage(message, attestation);
// 2) 从消息体里解析出金额、接收方、hookData 里的 messageHash
(uint256 amount, address recipient, bytes32 messageHash) = _parse(message);
// 缺失校验 A:message.header.sender 是否等于源链官方 TokenMessengerV2?
// 缺失校验 B:message.body.recipient 是否等于本链 Circle TokenMessengerV2?
// 缺失校验 C:Router 的 USDC 余额是否真的因为这次调用而增加了 amount?
// 3) 直接落账:写下一条可赎回记录
receivedTokenAmount[messageHash] = amount; // 攻击者在这里拿到了 1,000,000 的凭据
}
// Router(Base 侧)—— 提现侧
function receiveToken(bytes32 messageHash) external {
uint256 credited = ICCTPTokenMessenger(cctpMessenger).receivedTokenAmount(messageHash);
require(credited > 0, "no credit"); // 只验「有没有记录」,不验「钱是不是真到过」
uint256 payout = credited - credited * fee / 1000; // 0.1% 手续费
IERC20(usdc).transfer(msg.sender, payout); // 从 Router 的真实余额里付款
}
三处缺失可以这样理解:
| 校验 | 应该问的问题 | 缺失后的后果 |
|---|---|---|
A. sender |
这条消息是不是官方 TokenMessenger 代发的? | 任何人直接调 sendMessage() 伪造的消息都被当成真转账 |
B. recipient |
铸币的接收方是不是本链官方 TokenMessengerV2? | 消息可以指向攻击者自己的合约,绕开真实铸币路径 |
| C. 余额 | Router 的 USDC 实际增加了吗? | 账面凭据与真实资产脱钩,凭据可以凭空产生 |
A 和 B 是消息来源可信性问题,C 是账实一致性问题。三者任意一个到位,这次攻击都不成立——这也是为什么它更像一次纵深防御全线失守,而不是单点疏忽。
两个细节:为什么等 24 天,为什么用闪电贷
这两个动作其实回答的是同一个问题:伪造记账是免费的,但提现要花 Router 的真金白银。
- 伪造凭据写的是 100 万,而
receiveToken最终要从 Router 的真实 USDC 余额里转账。Router 平时余额远低于 100 万,凭据再大也提不出来。 - 于是攻击者用闪电贷把余额顶到 100 万。但闪电贷借来的钱最后要还,还款那部分是零和的——真正的利润只能来自 Router 里原本就属于别人的钱。
- 所以他必须等到 Router 余额尽可能高的时刻。8 月 19 日那笔 191,156 USDC 的合法入账就是他等的窗口,6 秒的反应速度说明这是自动化监控在盯着链上事件。
换个角度看:闪电贷在这里不是漏洞的一部分,而是放大器。真正的漏洞是凭据可以凭空创造;闪电贷只是让攻击者能把一张 100 万的假支票,兑现到 Router 余额允许的上限。
修复方向
SlowMist 给出的三条防线,正好对应上面三处缺失:
- 验来源:消息头的
sender必须等于配置好的、源链上可信的TokenMessengerV2地址(按sourceDomain分别配置白名单)。 - 验去向:消息体里的
recipient必须限定为本链 Circle 官方的TokenMessengerV2,确保走的是真实铸币路径。 - 验账实:只有在 Router 的 USDC 余额确实因本次铸币而增加之后,才创建可赎回记录。实现上就是在调用前后各读一次
balanceOf,用差值而不是消息里声明的金额入账。
第 3 条尤其值得强调。它是最笨的一条,却也是唯一一条不依赖你对上游协议语义理解正确的兜底:哪怕对 CCTP 的消息格式有误解,只要以实际余额差为准记账,凭空凭据就不可能产生。
更一般的教训
这次事故可以浓缩成一句话:
有效签名不等于有效资产。
跨链桥的本质是在目标链上复述一件发生在源链的事。签名回答的是「这句话是不是那边说的」,而不是「那边说的这件事是不是真的发生了」。当上游把一个通用消息通道(sendMessage)和一个资产通道(depositForBurn)复用同一套签名基础设施时,区分两者的责任就落到了集成方身上。
集成任何「消息 + 签名」式跨链协议时,值得逐条过一遍的清单:
- 这条消息的发送者是谁?是不是我信任的那个合约,而不只是「某个官方合约转发的」?
- 这条消息的来源域是不是我配置过的链?每条链的可信发送者是否分别配置?
- 消息里声明的接收方 / 回调目标是否被限定在白名单内?
- 消息声明的金额,和我这边实际收到的资产,是不是对得上?记账用的是哪一个?
- 同一条消息能否被重放?
messageHash的消费是否幂等? - 这些校验在单元测试里有没有对应的负例?(伪造 sender、伪造 recipient、金额不匹配)
最后一条常常是分水岭。前面几条写下来都不难,难的是有人真的去构造一条「签名有效但资产不存在」的消息来跑一遍测试——而攻击者恰恰是最认真做这件事的人。