时间 2021 年 8 月 10 日
涉案 约 $6.11 亿(当时史上最大)
结局 ⭐ 攻击者数日内全额归还
类别 C2 权限与身份假设失效

一、背景:一座桥的两个合约

Poly Network 是一个多链互通协议。它在以太坊侧的核心是两个合约:

┌──────────────────────────────────────┐
│  EthCrossChainData                   │
│  ⭐ 存放"当前的跨链签名者公钥"        │
│     —— 桥的全部信任根就是这一个变量    │
│                                      │
│  putCurEpochConPubKeyBytes(bytes)    │
│     ⚠️ onlyOwner                      │
└──────────────────────────────────────┘
              ▲ owner
              │
┌─────────────┴────────────────────────┐
│  EthCrossChainManager                │
│  ⭐ 它是上面那个合约的 owner           │
│                                      │
│  verifyHeaderAndExecuteTx(...)       │
│    ① 校验消息带的签名是否来自签名者   │
│    ② 校验通过后,【执行消息里指定的调用】│
└──────────────────────────────────────┘

跨链的正常流程:其他链上发生了一笔转账 → 签名者对这个事实签名 → 有人把消息 + 签名提交给 EthCrossChainManager → 校验通过 → Manager 去调用目标合约(比如资金池的 unlock),把钱放出来。

注意这个结构里的权力关系ManagerData 的 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 亿净损失。

思考题

  1. 用自己的话解释"混淆代理人”。为什么说 Manager"不是被攻破,是被当枪使了”?
  2. 攻击者需要做多少次哈希才能找到那个碰撞方法名?为什么说 4 字节"不是密码学强度"?
  3. 签名校验这一步是通过的。请说明:为什么校验通过反而是这次攻击成立的前提?
  4. _executeCrossChainTx 用白名单重写。为什么黑名单在这里一定不够?
  5. 画出"特权与转发分离"的结构图,并论证:即使 Executor 被诱导调用任意目标,为什么攻击会失败?
  6. 一个合约提供 execute(address, bytes) onlyOwner。请列举三种它会变成灾难的具体情形。
  7. multicallmsg.value 对每个子调用都是同一个值。构造一个利用这一点重复记账的攻击。
  8. “签名必须覆盖会影响执行结果的每一个字节。“请检查一个你熟悉的签名授权设计,看它有没有漏掉某个字段。
  9. 画出 Poly Network 的权限图(节点=合约,边=“能以特权调用”)。指出那条致命的边,并说明加时间锁会切断它的哪一段。
  10. 对比本篇和第 4 篇:两者都是"权限失效”,但一个是没有检查,一个是检查通过了。这个差别对你安排审计注意力有什么启示?