区块链
Mezo:签名验证过了,合约为何认错了人
Mezo 的 veBTC 用户在 10 月 5 日被转走 37 个锁仓凭证,涉及约 5.52 BTC。攻击者签署了自己的请求,跨库执行时却被当成资产主人。项目随后通过升级停用了这条代付路径。

文章导航
10 月 5 日 18:54:52 UTC,Mezo 上的 veBTC 凭证 826 换了主人。原持有人地址是 0xb11c…5091,接收者是 0x1833…0491。62 秒后,接收者调用 withdraw(826),凭证被销毁,对应的约 3.3667 BTC 转进接收者钱包。两笔交易都成功了,前一笔甚至经过一个专门验证签名的转发器。
转发器验证的是接收者自己的请求,NFT 库执行时却把请求的发送者认成了原持有人。身份在调用中途换掉,后面的所有权检查照常运行,结果仍然放行。这个错误把一个原本用来方便用户的代付入口,变成了转走别人仓位的通道。
1 一张 NFT,带着取回 BTC 的权利
Mezo 是支持 EVM 合约的区块链。它的 veBTC 锁仓让用户存入 BTC,获得代表这笔仓位的 NFT。凭证同时关联投票权,锁定时间影响权重。锁仓到期,持有人可以赎回本金,NFT 也随之销毁。到期不会自动把钱送回钱包,尚未赎回的凭证仍然对应合约里的资产。
这次的 826 号凭证记录正好把这个过程留下了。第一笔交易发出 ERC-721 Transfer,从原持有人转到攻击地址。第二笔交易发出 Withdraw,原始数量为 3366676916317053701,按 18 位小数换算为 3.366676916317053701 BTC,与官方受影响仓位表中取九位小数的数字对应。
赎回函数本身保留了多项限制。旧实现的 contracts/ve/Escrow.sol,withdraw():679–718检查调用者是不是所有者或获授权者、仓位类型、投票状态及是否到期,再销毁 NFT、清空锁仓记录并转出资产。攻击者先取得凭证,直接赎回时便已能通过所有权检查。到期检查则解释了为什么被选中的仓位都能迅速变现。
2 转发器验证了谁,合约该怎样知道
代付交易解决一个实际麻烦。用户可以先在链下签署操作请求,由别人付链上手续费。目标合约看到的直接调用者是转发器,签名的用户则藏在请求里。ERC-2771约定,可信转发器在校验后把用户地址追加到调用数据末尾。地址占 20 字节,目标合约先确认调用来自可信转发器,再读取末尾这 20 字节。
Mezo 的实际转发器是 0xE6F9…7085。其验证源码 contracts/forwarder/MezoForwarder.sol 中,execute():71–104先执行签名与 nonce 检查,再用 abi.encodePacked(req.data, req.from)追加地址。_verifySig():171–195把恢复出的签名地址与 req.from比较。nonce 是同一发送者的请求序号,用来阻止原请求被重复执行。
826 号凭证的转移请求里,req.from是攻击地址 0x1833…0491,req.to是 veBTC 代理。代理是固定的访问地址,把执行交给背后的可升级实现。其验证源码 @openzeppelin/contracts/proxy/Proxy.sol:22–31复制整份调用数据再执行委托调用,这一步保留了转发器追加的后缀。转发器送进 veBTC 的身份原本就是攻击者。合法签署一份请求,只证明这个地址同意发起请求,接下来仍应由 veBTC 判断它有没有权利转走别人的 NFT。
旧的 contracts/ve/VeERC2771Context.sol:20–32负责取得这个身份。代码检查可信转发器和数据长度,然后从当前调用数据末尾读取地址。直接调用则返回原始 msg.sender。问题出在“当前调用数据”这几个字,它们在下一步已经指向另一段内容。
// 旧实现的身份读取分支
if (msg.data.length >= 20 && isTrustedForwarder(self, msg.sender)) {
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return msg.sender;
}
3 进入外部库,身份后缀没跟过来
veBTC 的逻辑分散在外部 Solidity 库中。这样可以分担单个合约的代码体积,转移 NFT 就交给 NFT 库。旧 contracts/VotingEscrow.sol:218–224接收 safeTransferFrom(from, to, tokenId, data),随后调用 self.safeTransferFrom(...)。库里的同名函数声明为 external,编译器为它构造新参数并执行 DELEGATECALL。
Solidity 的外部库规则保留调用者的存储、msg.sender和 msg.value,新一层执行拿到的 msg.data却是库函数自己的参数编码。外层转发器额外附上的 20 字节,没有对应的函数参数,编译器自然不会把它作为参数继续传下去。于是库看到的 msg.sender仍然是可信转发器,数据末尾却已经来自重新编码的参数。
safeTransferFrom的 data是用户可以提供的字节数组,正常用于向接收 NFT 的合约传递附加信息。826 的真实交易在这个参数中放入了 32 字节:前 12 字节为零,后 20 字节是原持有人的地址。它恰好占满一个 ABI 编码单元,库调用数据的末尾 20 字节因此仍是完整地址。如果只放 20 字节,动态参数的尾部补齐还会改变最后读到的内容。到了库执行阶段,旧 _msgSender()便把原持有人读成发送者。转发器正确追加过的攻击者地址,已经不在这份库调用数据中。
转发器确认的是外层请求中的签名身份,库内的地址读取却依赖外层数据格式还在。委托调用保留了 msg.sender,编译器重新构造的库参数替换了 msg.data。这与代理直接复制原数据的做法不同。调用者仍是可信转发器,末尾地址却来自用户参数,两者已经失去原来的对应关系。
下一步就直接改动资产归属。contracts/ve/NFT.sol:50–58、88–113、185–195中,safeTransferFrom()把这个错误发送者交给 _transferFrom(),后者通过 _isApprovedOrOwner()校验,再从旧持有人的列表移除 NFT,把它加入新持有人的列表。校验逻辑认得所有者,传给它的地址却已被替换。
对比另一个入口,差别更清楚。同一旧版本的 VotingEscrow.sol:200–205在 transferFrom()里先取得 self._msgSender(),再把解析结果作为明确的 _sender传给内部转移函数。发生错误的安全转移入口把解析推迟到了外部库内部。名字里有 safe,主要表示它会检查合约接收者能否接收 NFT,并不自动补上跨调用的身份传递。
4 关闭代付入口,保留正常所有权检查
攻击者在 10 月 5 日 19:09 UTC 已将所得桥接出 Mezo。官方复盘记录,团队次日 08:45 发现异常,09:17 暂停桥接提现,09:27 停止全链交易,修补后才恢复。停桥交易成功时间为 09:17:34。这次应急关闭控制了继续利用的机会,前一晚已转出的资金还需要另行追回或补偿。
这次永久修补选择了停用元交易,也就是上述链下签名代付机制。新的 contracts/ve/VotingEscrowState.sol:275–283把 _msgSender()改为直接返回 msg.sender。NFT、锁仓和授权相关库改用这个实现,不再从参数末尾读取身份。即使调用来自原转发器,它也只能以转发器自身的地址接受权限检查,用户参数不能再把它变成 NFT 主人。
// 新实现的身份读取
function _msgSender(Storage storage) internal view returns (address) {
return msg.sender;
}
修补还处理了已部署代理的旧配置。新实现的 contracts/VotingEscrow.sol:84–94与 contracts/VeBTC.sol:21–26提供二次初始化入口 initializeV2(),把存储中的 trustedForwarder写为零。保留原存储字段可以维持代理布局,清零则让后来创建的托管仓位奖励合约也不再继承旧转发器设置。
升级确实执行了。代理 0x3D4b…f279的 升级交易在区块 12330216成功,时间是 10 月 6 日 15:01:23 UTC。同一代理先发出指向新实现 0x41d0…5aE3的 Upgraded事件,随后发出 Initialized(2),与批量调用中的 initializeV2()相符。此前历史实现是 0x2a05…e65c。两个实现的 version()都仍返回 1.0.0,单看这个版本字符串会漏掉真正的修补差别。回退到旧实现也会把按数据尾部读取身份的逻辑带回来,不能把它当成安全的故障恢复版本。
这项取舍有明确的功能代价。依赖旧代付方式的集成需要改成用户直接调用,才能让权限检查取得用户自己的地址。Mezo 在事件复盘中说明,当时没有生产功能使用这项能力。关闭闲置入口因此比重新设计全链路身份传播更直接。其他项目如果正在提供代付功能,就需要在外层解析一次身份,将它明确传递到后续逻辑,并为全部入口重新建立授权测试。
5 测试应当跟到 NFT 换主人的那一刻
如果单元测试只覆盖所有者直接转移 NFT,msg.sender本来就正确,这个错误便不会出现。再给转发器一个错误签名,请求会在外层被拒绝,仍然碰不到库里的身份读取。修补验收需要补上两者之间的路径:有效签名来自一个普通地址,目标 NFT 属于另一个地址,请求经过真实外部库后必须保持拒绝。
修补后的回归测试需要让这条请求真正抵达库里的授权判断,并确认 NFT 主人和仓位余额都没有改变。再用所有者直接调用作为正向对照,验证正常转移及到期赎回仍然工作。接收方为合约时,也应观察 onERC721Received中收到的操作人和原持有人,避免把错误身份继续传给下游。只检查交易顶层是否成功,覆盖不了资产状态和回调参数。
另一个应当保留的对照是不同入口的差异。带 data与不带 data的安全转移、普通 transferFrom()、授权、提取和托管路径,各自在什么位置解析身份,要逐项看实际调用。把一个安全入口测通,不能替代另外几个调用栈。由库化重构引入的错误,也应在以后调整函数可见性、把内部函数移为外部库时重新检查。
6 合约修好了,用户的钱仍要逐笔对上
官方统计为 36 名持有人、37 个仓位,合计 5.519271401 BTC。Mezo 在 10 月 7 日的公告中承诺用协议自有资金补偿,直接把 BTC 转给受影响钱包,无需用户另开领取网站。
这份公告没有附上覆盖 36 个钱包的赔付完成对账。受影响用户可以检查自己的钱包是否收到对应数额的 BTC。网站上另行要求连接钱包或签名领取的页面,不属于这份补偿安排。