UNCX 漏洞复盘:一个过期的份额计数器,如何让共享金库被掏空
UNCX 漏洞复盘:一个过期的份额计数器,如何让共享金库被掏空
先把金额放一边。UNCX Network 这次事件最终实现约 80.8 万美元损失,跨四条链,数字不算大,很容易被当成又一条 DeFi 快讯划过去。但真正值得看的不是金额,而是一个号称“锁仓代币不能转、不能卖、不能加流动性、不能质押”的共享归属池,被一串普通公开函数调用掏空了。攻击者没有管理员私钥,没有治理投票,也没有拿到任何代币持有人的签名。他只是比合约更懂合约自己的账本。
下面按攻击实际发生的顺序拆开讲,每一步都说明为什么会成立,以及代码应该怎么改。
一、问题的起点:一个池子,两个计数器,中间有缝
UNCX Vesting V2 以 VestingManager 部署,把每个项目存入的同一种代币汇进同一个余额。单个归属仓位并不直接持有代币,而是持有这个池子的份额。当某个仓位调用 release() 时,合约用池子当前余额和该仓位占总份额的比例,把份额换成代币。
整个系统靠两个记账变量撑着:
_totalTokenShares[token]:全局分母,池子里所有未兑付份额的总和。_depositedShares[vestingId]:单个仓位的分子,记录这个仓位理论上拥有多少份额。
每个存款人默认依赖一条不变量:_totalTokenShares 必须始终等于所有仓位仍然有效的份额之和。任何仓位都不应该取出超过它存进去的东西。但合约没有强制这条不变量,甚至没有让两个计数器保持同步对话。
二、攻击步骤:每一步都是合法调用
第一步:释放一个大仓位,但把它的“幽灵记录”留在原地
仓位调用 release() 时,函数会从全局 _totalTokenShares 里减掉已领取份额。这一步本身没错,池子分母变小了,反映这些份额已经被兑付。
问题在于 release() 没有做另一件事:它没有清掉该仓位 _depositedShares 里的已释放份额。全局计数器动了,局部计数器还停在最初存入的数字。从那个区块开始,合约对同一批份额持有两份互相矛盾的记录:一份说它们已经离开池子,另一份说它们仍属于某个仓位。
第二步:创建“一出生就能释放”的辅助仓位
攻击者开了几个辅助归属仓位,把时间表设计成在创建它们的区块前一秒结束。因为时间表已经走完,这些仓位在诞生的瞬间就是完全可释放的。这本身不是漏洞,归属合约允许任何人创建时间表,这是正常功能。但它给了攻击者一批随用随弃的份额容器,可以立刻推过 release() 和 cancel(),不需要等任何真实时间。
第三步:直接向池子捐代币,把份额价格抬起来
在取消仓位之前,攻击者把代币直接转进 VestingManager 合约,不走 createVesting,也不走 topUpVesting。这笔捐赠不会 mint 新份额,只是抬高池子的代币余额,而份额数量没变。用份额的语言说,每个现有份额现在能 claim 更大一块池子。
捐赠不是根因,它是加速器。它把“一个份额值多少”和“陈旧局部计数器还声称多少”之间的差距拉大。
第四步:取消已释放仓位,同一批份额被减两次
stale 计数器在这里造成实质伤害。对一个已经释放过的仓位调用 cancel() 时,函数用仓位完整的 _depositedShares 记录来计算要从全局总数里移除多少份额。这个记录是生命周期存入数,没有更新过。它没有减掉 release() 已经从全局总数里移除的那部分。
用 Solidity 表示,漏洞逻辑大概是这样:
// 有问题的 cancel() 行为
uint256 sharesToRemove = position.depositedShares; // 陈旧值,包含已释放份额
_totalTokenShares[token] -= sharesToRemove; // 全局总数第二次下降
delete position;
同一批份额从分母上被扣了两次:一次在 release() 里,一次在 cancel() 里。池子的代币余额没有变,但声称这些余额的份额数量,已经被压到低于存款实际支撑的水平。
第五步:用缩小的分母释放最后一个小仓位
全局份额数被人工压低后,攻击者释放最后站着的那个仓位。赔付公式是 poolBalance * (positionShares / totalShares)。分子正常,分母被推到了不该有的低位。这个仓位付出的代币,来自其他项目存进来的钱。
整个序列包在一笔闪电贷里,同一交易内还清,然后在 EDGEN、ZC、pepecoin、LO0P 和 PAAL 上重复。到第二天,模仿钱包和 MEV 机器人已经把模板复制到 BNB Chain、Base 和 Robinhood Chain,总共产生 11 笔 drain 交易。
三、修复方案:一个函数必须同时更新两个计数器,并且把不变量变成测试
补丁分两层,都不复杂。第一层是机械修正,第二层是结构约束。
第一层:每个改状态的函数里,同步修正仓位记录
任何碰 _totalTokenShares 的函数,都必须在同一步调整对应的仓位份额记录。release() 应该在仓位还能走到 cancel() 之前,把 position.depositedShares 按已领取数量减掉或清零。
function release(uint256 vestingId) external {
VestingPosition storage position = positions[vestingId];
uint256 claimableShares = _claimableShares(position);
require(claimableShares > 0, "nothing to release");
// 先更新仓位记录,再更新全局计数器
position.depositedShares -= claimableShares;
_totalTokenShares[token] -= claimableShares;
uint256 payout = _sharesToTokens(claimableShares, token);
token.safeTransfer(position.owner, payout);
}
这样 cancel() 再也读不到陈旧的存入数字。仓位记录的份额只反映还没释放的部分。同一批份额不可能从全局总数上被扣两次,因为第一次释放后,局部记录里已经没有它们了。
效果很直接:攻击者的第四步失去输入。cancel() 没有已释放份额可以从全局分母里再减,因为 release() 已经把它们从局部计数器里拿掉了。捐赠仍然会移动份额价格,但取消操作不会再通过重复计算不存在的份额来放大扭曲。
第二层:把不变量写成显式规则,并用调用序列去撞它
机械修复能堵住已知路径,但不能证明没有其他函数或函数组合会破坏全局分母与局部分子之和的关系。第二层就是把这条关系当成一等不变量,用攻击者的方式去测试。
不变量本身很好说:
_totalTokenShares[token] == sum(position.depositedShares for every live position of that token)
在 Foundry 里,可以用有状态不变量测试直接编码这条规则:
function invariant_totalSharesMatchPositions() public {
for (uint256 i = 0; i < tokens.length; i++) {
uint256 sumOfPositions;
for (uint256 j = 0; j < vestingIds.length; j++) {
if (positions[vestingIds[j]].token == tokens[i] && positions[vestingIds[j]].active) {
sumOfPositions += positions[vestingIds[j]].depositedShares;
}
}
assertEq(_totalTokenShares[tokens[i]], sumOfPositions);
}
}
然后让 fuzzer 把 createVesting、topUpVesting、release、cancel 按任意顺序串起来,包括创建时就已经成熟的 schedule。10 月 14 日那条序列,创建、释放、捐赠、top up、取消、释放剩余,就变成一个测试用例:在有漏洞的构建上失败,在同步后的构建上通过。不变量把这次攻击变成开发流水线每次合并都要检查的属性。
四、UNCX 这件事真正说明的共享池安全问题
这次利用最让人不舒服的地方,不是存在记账 bug。记账 bug 很常见。不舒服的是,这个漏洞按设计就是无需权限的。任何地址都可以创建归属、top up、release、cancel、捐代币。每一步都是普通用户操作。攻击者不需要攻破 owner key,不需要绕过角色,也不需要找签名重放。他只需要按合约没考虑过的顺序调用公开函数。
做共享池合约的人,从 UNCX 应该带走这几点:
- 共享池不是一堆孤立用户余额的集合,它是一个单一的算术面。一个函数更新全局总数,另一个函数更新仓位记录,这等于给缺口留门,最后买单的是别人的存款。
- 直接捐代币不是白送的钱。在任何基于份额的金库里,捐赠会改变份额价格但不 mint 份额。只要合约里有一个地方存在陈旧分子,捐赠就是杠杆,能把记账漂移变成抽干。
- “任何仓位不能取出超过它存入的东西”不是一句注释,它是不变量。它应该写进代码,用用户能做的每一种调用序列去 fuzz,并且在合约上链之前跑在每个 pull request 上。上链之后,任何人复制这套利用都用不了一小时。
总结
UNCX 在四条链上实现约 80.8 万美元损失,原因是 release() 和 cancel() 对“已释放份额归谁”这件事说法不一致。修复很小:每个改变全局份额数的函数,必须在同一步改变仓位记录;把总份额相等变成部署前必跑的 fuzz 不变量。更大的点是,在共享归属池里,合约的记账就是安全边界。两个计数器一旦不再一致,那道缝不属于合约,它属于每一个相信这个池子的存款人。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)