| 时间 | 2021 年 8 月 10 日 |
| 涉案 | 约 $6.11 亿(当时史上最大) |
| 结局 | ⭐ 攻击者数日内全额归还 |
| 类别 | C2 权限与身份假设失效 |
一、背景:一座桥的两个合约
Poly Network 是一个多链互通协议。它在以太坊侧的核心是两个合约:
┌──────────────────────────────────────┐
│ EthCrossChainData │
│ ⭐ 存放"当前的跨链签名者公钥" │
│ —— 桥的全部信任根就是这一个变量 │
│ │
│ putCurEpochConPubKeyBytes(bytes) │
│ ⚠️ onlyOwner │
└──────────────────────────────────────┘
▲ owner
│
┌─────────────┴────────────────────────┐
│ EthCrossChainManager │
│ ⭐ 它是上面那个合约的 owner │
│ │
│ verifyHeaderAndExecuteTx(...) │
│ ① 校验消息带的签名是否来自签名者 │
│ ② 校验通过后,【执行消息里指定的调用】│
└──────────────────────────────────────┘
跨链的正常流程:其他链上发生了一笔转账 → 签名者对这个事实签名 → 有人把消息 + 签名提交给 EthCrossChainManager → 校验通过 → Manager 去调用目标合约(比如资金池的 unlock),把钱放出来。
⭐ 注意这个结构里的权力关系:Manager 是 Data 的 owner,也就是说 —— Manager 有权修改桥的信任根。这个设计本身是为了让签名者名单可以随 epoch 轮换。
二、漏洞在哪
问题在 verifyHeaderAndExecuteTx 校验通过之后的执行环节:
function _executeCrossChainTx(
address _toContract, // ⚠️ 目标合约:来自跨链消息
bytes memory _method, // ⚠️ 方法名:来自跨链消息(字符串)
bytes memory _args,
...
) internal returns (bool) {
// 检查目标是合约而不是外部账户
require(isContract(_toContract), "target not contract");
// ⚠️ 从【消息里的字符串】拼出函数选择器
bytes memory returnData;
(success, returnData) = _toContract.call(
abi.encodePacked(
bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))),
abi.encode(_args, _fromContractAddr, _fromChainId)
)
);
...
}
三个问题叠在一起:
① 目标合约地址完全由消息指定
⟹ 只检查了"是合约",没检查"是不是【允许被调用的】合约"
⚠️ 没有白名单
② 函数选择器由消息里的【字符串】哈希出来
⟹ 攻击者可以控制最终产生哪个 4 字节选择器
③ ⭐ 而发起这个 call 的是 Manager ——
它恰好是 EthCrossChainData 的 owner
⭐⭐ 这三条合起来就是经典的「混淆代理人」(confused deputy)攻击:
一个有权限的实体,被诱导去代替没有权限的人执行操作。 Manager 不是被攻破了,它是被当枪使了——它老老实实地按消息指示去调用,而它自己的身份带着 owner 权限。
三、攻击复盘
第一步:找一个能撞上目标选择器的方法名
攻击者要调用的是:
putCurEpochConPubKeyBytes(bytes) 选择器 = 0x41973cd9
⚠️ 但代码会把消息里的 _method 和固定后缀 "(bytes,bytes,uint64)" 拼起来再哈希。所以不能直接传函数名。
⭐ 攻击者做的事:暴力搜索一个字符串 s,使得
bytes4( keccak256( s + "(bytes,bytes,uint64)" ) ) == 0x41973cd9
⟹ 这是在 32 位空间里找碰撞。
期望尝试次数 ≈ 2³² ≈ 43 亿 —— 普通电脑几分钟到几小时的事。
⭐⭐ 函数选择器只有 4 个字节,这不是密码学强度。 它的设计目的是节省 calldata,从来不是防伪造——但大量代码把它当成了后者。
第二步:让 Manager 改掉信任根
构造一条跨链消息:
_toContract = EthCrossChainData 的地址
_method = 那个暴力搜出来的字符串
_args = 攻击者自己的公钥
提交给 verifyHeaderAndExecuteTx
│
├─ ① 签名校验 …… ⚠️ 这一步是【通过】的
│ (攻击者构造的是一条格式合法的消息,
│ 走的是正常的跨链消息通道)
│
└─ ② 执行:Manager.call(EthCrossChainData, 0x41973cd9, 攻击者公钥)
└─ EthCrossChainData 检查 onlyOwner
⟹ msg.sender 是 Manager ✅ 通过
└─ ⭐ 签名者公钥被改成攻击者的
第三步:以签名者身份提走一切
信任根已经是攻击者的公钥了。此后他可以自己签发任意跨链消息:
"某某链上有人存入了 X" ⟹ 自己签名 ⟹ 提交 ⟹ 以太坊侧放款
在以太坊、BSC、Polygon 三条链上合计取走约 $6.11 亿。
结局
攻击者在数天内全额归还了资金,并留下多条链上消息说明动机。Poly Network 公开称其为"白帽"并提出赏金。
⚠️ 但这个结局不能被当成安全设计的一部分:
⭐ 这次的对手恰好是一个愿意归还的人。
同样的漏洞,换一个对手,结果就是 6.11 亿的净损失。
⟹ [第 22 篇](https://blog.ifcalm.org/posts/security/blockchain/22-ronin-bridge/) 那一侧的对手,
一次都没有归还过。
四、为什么没被发现
① 审计的注意力都在密码学上
一座桥的审计清单通常是:
· 签名校验对不对? ✅ 对
· 门槛是多少?合谋成本? ✅ 合理
· Merkle 证明的验证正确吗? ✅ 正确
· 重放保护有吗? ✅ 有
⚠️ 而漏洞在【校验通过之后】那一段 ——
"验完了,然后我们去执行它"。
⭐ 这一段看起来是"业务逻辑",不是"安全逻辑"。
② 权限关系画在图上才看得出来
"Manager 是 Data 的 owner" ——
这句话单独看完全合理(需要轮换签名者名单)。
"Manager 可以调用任意合约的任意方法" ——
这句话单独看也像是"通用性设计"。
⭐ 两句话【放在一起】才是漏洞。
⚠️ 而它们分布在两个不同的文件里。
③ 4 字节选择器被默认当成不可伪造
写代码的人的心智模型:
"方法名是我们协议内部约定的,
外部不会知道该传什么,也构造不出别的。"
⚠️ 现实:
4 字节 = 43 亿种可能,普通电脑几分钟就能穷举。
⭐ 而且所有常见函数的选择器都有公开数据库可查。
五、防御
① 白名单,而不是黑名单
mapping(address => bool) public allowedTargets;
function _executeCrossChainTx(address _to, ...) internal {
require(allowedTargets[_to], "target not allowed"); // ⭐
...
}
⚠️ 不要写成"禁止调用 EthCrossChainData"这种黑名单——你永远列不全危险目标,而且新增一个特权合约时很容易忘记加进去。
② 特权与转发必须分离
⭐⭐ 一个持有特权的合约,绝不能同时充当通用的调用转发器。
正确的结构:
┌────────────────┐ ┌──────────────────┐
│ Manager │ │ Executor │
│ 持有 owner 权限 │ │ ⭐ 零权限 │
│ 只做校验 │───────▶│ 负责转发调用 │
└────────────────┘ └──────────────────┘
⟹ 即使 Executor 被诱导去调用任意目标,
它的身份【什么权限都没有】,调用会失败。
这是最小权限原则在合约设计上的直接应用:把"能签发"和"能执行"拆成两个身份。
③ 不要从用户数据构造选择器
// ⚠️ 危险:选择器来自外部数据
bytes4 sel = bytes4(keccak256(abi.encodePacked(userProvidedName, "(...)")));
// ✅ 安全:用一个枚举做映射,选择器写死在代码里
enum Action { Unlock, Mint, Burn }
if (action == Action.Unlock) IPool(pool).unlock(args);
else if (action == Action.Mint) IToken(token).mint(args);
else revert("unknown action");
⭐ 一般原则:调用的"目标"和"方法"这两样东西,都应该来自你自己的代码,而不是来自输入。
④ 关键状态的变更要独立于业务通道
⭐ "更换签名者公钥" 这件事,
不应该和 "处理一笔普通跨链转账" 走同一条代码路径。
正确做法:
· 单独的函数、单独的权限、单独的事件
· ⭐ 加时间锁 —— 换信任根这种事,没有理由必须立即生效
· 加监控告警 —— 信任根变更必须有人在几分钟内知道
⚠️ 如果 Poly Network 的签名者变更有 24 小时时间锁,这次事故的损失是零。
六、⭐ 举一反三
核心命题
⭐⭐ 当合约 A 有权限,而 A 的行为可以被外部数据操纵时,攻击者就间接拥有了 A 的权限。
攻破一个系统不需要偷它的钥匙,只需要找到一个愿意替你开门的人。
混淆代理人的其它形态
① 万能的 execute / multicall
// ⚠️ 一个持有资金或权限的合约,提供了这样的入口:
function execute(address target, bytes calldata data) external onlyOwner {
target.call(data);
}
⚠️ 只要 owner 被钓鱼一次(或者 owner 是另一个可被操纵的合约),它能做的事等于这个合约能做的一切。
⭐ multicall + msg.value 还有一个经典坑:在一次 multicall 里,msg.value 对每个子调用都是同一个值——如果子函数用 msg.value 记账,一份 ETH 会被重复计算多次。
② approve 给了一个通用转发合约
用户对某个路由合约做了无限授权。
⚠️ 如果该路由有"调用任意目标的任意方法"的功能,
攻击者就能借它去调用
token.transferFrom(用户, 攻击者, 全部)
⭐ 这类"任意调用路由"是钱包被掏空的常见原因之一。
③ 签名校验和执行之间的"语义漂移"
⚠️ 签名者签的是"含义 X",而合约执行的是"字节 Y"。
· 签名覆盖了 hash(a, b),而执行时把 a、b 拼接方式改了
⟹ 不同的 (a,b) 产生同一个 hash(拼接歧义)
· 签名覆盖了 amount,但没覆盖 recipient
· ⭐ 签名覆盖了参数,但没覆盖【调用哪个函数】← 本案的形态
⟹ 判据:签名必须覆盖【会影响执行结果的每一个字节】。
④ 跨合约的权限继承
画一张图,节点是合约,边是"A 能以特权身份调用 B"。
⭐ 然后问:从【任何一个外部可触达的入口】出发,
沿着这些边能走到哪里?
⚠️ 走得到的地方,就是攻击面。
Poly Network 的图上有一条从"提交跨链消息"直达"改信任根"的边。
关于四字节选择器
⭐ 记住这条:4 字节选择器【不具备任何抗碰撞性】。
不要用它来:
· 证明调用者知道某个秘密
· 区分可信/不可信的调用
· 做任何形式的鉴权
它唯一的用途是:在【已知的、固定的】函数集合里做路由。
七、本案小结
- 攻击者没有偷任何私钥、没有破解任何密码学。 他让桥的管理合约"自己"把签名者公钥改成了他的。
- ⭐⭐ 经典的混淆代理人攻击:Manager 没被攻破,它是被当枪使了——老实按消息指示调用,而它的身份自带
owner权限。 - 三个问题叠加:目标地址来自消息且无白名单 + 函数选择器由消息中的字符串哈希得出 + 发起调用的 Manager 恰好是 Data 的 owner。
-⭐ 4 字节选择器可以被暴力碰撞:
2³²≈ 43 亿,普通电脑几分钟到几小时。它的设计目的是省 calldata,不是防伪造——但大量代码把它当成了后者。 - ⚠️ 审计的注意力全在密码学上(签名、门槛、Merkle 证明、重放),而漏洞在"验完了,然后我们去执行它"这一段——它看起来是业务逻辑,不是安全逻辑。
- 两句各自合理的话放在一起才是漏洞:“Manager 是 Data 的 owner"合理,“Manager 可以调用任意合约"也像通用性设计——而它们分布在两个文件里。
- 防御核心:特权与转发必须分离。 一个持有特权的合约,绝不能同时充当通用调用转发器。校验身份和执行身份要拆开,执行方零权限。
- 调用的"目标"和"方法"都必须来自你自己的代码,不能来自输入。 用枚举映射到写死的调用,不要从用户字符串拼选择器。
- ⚠️ 如果签名者变更有 24 小时时间锁,本次损失是零。 换信任根这种事,没有理由必须立即生效。
- 全额归还不能算进安全设计:这次的对手恰好愿意还,换一个对手就是 6.11 亿净损失。
思考题
- 用自己的话解释"混淆代理人”。为什么说 Manager"不是被攻破,是被当枪使了”?
- 攻击者需要做多少次哈希才能找到那个碰撞方法名?为什么说 4 字节"不是密码学强度"?
- 签名校验这一步是通过的。请说明:为什么校验通过反而是这次攻击成立的前提?
- 把
_executeCrossChainTx用白名单重写。为什么黑名单在这里一定不够? - 画出"特权与转发分离"的结构图,并论证:即使 Executor 被诱导调用任意目标,为什么攻击会失败?
- 一个合约提供
execute(address, bytes) onlyOwner。请列举三种它会变成灾难的具体情形。 multicall中msg.value对每个子调用都是同一个值。构造一个利用这一点重复记账的攻击。- “签名必须覆盖会影响执行结果的每一个字节。“请检查一个你熟悉的签名授权设计,看它有没有漏掉某个字段。
- 画出 Poly Network 的权限图(节点=合约,边=“能以特权调用”)。指出那条致命的边,并说明加时间锁会切断它的哪一段。
- 对比本篇和第 4 篇:两者都是"权限失效”,但一个是没有检查,一个是检查通过了。这个差别对你安排审计注意力有什么启示?