过去一周(2026年10月12日–2026年10月18日),本文聚焦以下 3 起重大安全事件,估计损失总计约 387万 USD。
| 日期 | 事件 | 类型 | 估计损失 |
|---|---|---|---|
| 2026年10月12日 | Autheo Network | 私钥泄露 | 约 176万 USD |
| 2026年10月14日 | UNCX Network | 记账错误 | 约 80.7万 USD |
| 2026年10月18日 | Qubic Wallet | 弱种子生成 | 约 130万 USD |
损失数字是根据已实现价值估算的。
- UNCX Network:本文详细分析这一事件,因为其聚合份额计算缺陷提供了本周最清晰的合约记账教训。
本周焦点:UNCX Network
2026年10月14日,攻击者利用了 UNCX Network 在 Ethereum、BNB Chain、Base 和 Robinhood Chain 上的 Smart Token Vesting V2 合约。该事件涉及 11 笔已确认的资金抽取交易中的 10 种资产,已实现或经转移的价值约为 80.7609万 USD [1]。根本原因是聚合份额计算存在缺陷,将以不同代币与份额比率创建的归属期批次混在一起。这导致仓位级记录与全局份额供应量出现偏差,使攻击者控制的仓位得以提取超过攻击者投入量的代币。
背景
UNCX Network 是一家多链 DeFi 基础设施提供商,其 Smart Token Vesting V2 产品允许项目按照基于时间的归属计划锁定 ERC-20 代币。每项计划由一个归属 ID 表示,并记录代币、创建者、受益人、总量、已释放数量以及一个或多个解锁批次。允许追加存入的软锁定仓位在创建后可以接收更多批次,而其创建者可以取消尚未归属的部分。
同一代币的各仓位在 VestingManager 中共用一笔余额,协议使用份额来确定每个仓位对该余额的归属量。因此,其记账有两个相互关联的层面:
Global pool, per token
create / topUp: receive tokens and mint shares
release: convert releasable shares into tokens
_totalTokenShares[token]: active shares across all positions
Position record, per vesting ID
_depositedShares[id]: share basis assigned to the position
totalAmount: token amount represented by its tranches
released: shares already paid to the beneficiary
执行释放时,管理合约首先计算计划中已有多少部分归属。它使用 _depositedShares[vestingId] 将 totalAmount 的该归属比例换算为已归属份额,减去 released,并将结果称为 releasableShares。在下方公式中,tokenBalance 是管理合约当前持有的该计划所涉代币的 ERC-20 余额,由该代币的所有仓位共享;_totalTokenShares[token] 则是对应的全局有效份额供应量。代币支付量取决于两者的比率:
amount = releasableShares * tokenBalance / _totalTokenShares[token]
通过归属功能存入代币会同时增加代币余额和份额供应量。直接向管理合约转入 ERC-20 代币则只改变代币余额,因此会改变每份份额代表的代币数量。只有当每项生命周期操作都能使仓位记录与全局供应量保持一致时,这种份额模型才能支持池化仓位。
漏洞分析
受影响的 Ethereum VestingManager 位于 0xA98F...99F5,其中的聚合份额计算存在缺陷,可通过 release()、topUpVesting() 和 cancel() 加以利用。其他三个网络上也存在同样受影响的部署。
为便于比较状态变化,下文分析引入 D、R 和 V 作为简写。这些符号并未出现在合约代码中;下表将各符号映射到相应的合约变量或表达式。映射值分为两类数量:
| 符号 | 合约变量 / 表达式 | 数量类型 | 含义 |
|---|---|---|---|
D |
_depositedShares[vestingId] |
份额数量 | 分配给该仓位所有已记录存入款项的累计份额基数 |
R |
schedule.released |
份额数量 | 已向受益人支付的累计份额 |
| - | schedule.totalAmount |
代币数量 | 该仓位当前各批次记录的代币总量 |
| - | vestedAmount |
代币数量 | 当前时刻按照这些批次的计划已归属的 totalAmount 部分 |
V |
vestedShare |
份额数量 | 对 D 应用单一混合代币归属比例计算出的累计已归属份额 |
在 release() 中,合约计算 releasableShares,将 schedule.released 增加相应数量,并从 _totalTokenShares[schedule.token] 中减去同一数量。它不会减少 _depositedShares[vestingId]。因此,释放后,该仓位在全局供应量中仅占有 D - R 份有效份额,尽管其存储的基数仍为 D。
function release(uint256 vestingId) external nonReentrant {
...
uint256 releasableShares = _getReleasableShares(vestingId);
uint256 balance = IERC20(schedule.token).balanceOf(address(this));
uint256 amount =
(releasableShares * balance) / _totalTokenShares[schedule.token];
schedule.released += releasableShares;
_totalTokenShares[schedule.token] -= releasableShares;
IERC20(schedule.token).safeTransfer(msg.sender, amount);
}
cancel() 将单一混合归属比例应用于仓位的聚合份额基数。它首先通过 getVestedAmount(vestingId) 计算 vestedAmount,然后将以代币数量表示的已归属比例换算成份额,得到 V = (vestedAmount * D) / totalAmount。最后,该函数将 D - V 视为该仓位可移除的未归属份额。V 是累计值,并非先前支付后剩余的已归属份额。
function cancel(uint256 vestingId) external nonReentrant {
...
uint256 vestedAmount = getVestedAmount(vestingId);
uint256 remainingAmount = schedule.totalAmount - vestedAmount;
uint256 vestedShare =
vestedAmount * _depositedShares[vestingId] / schedule.totalAmount;
uint256 sharesToRemove =
_depositedShares[vestingId] - vestedShare;
_totalTokenShares[schedule.token] -= sharesToRemove;
_depositedShares[vestingId] = vestedShare;
schedule.totalAmount = vestedAmount;
schedule.cancelled = true;
IERC20(schedule.token).safeTransfer(schedule.creator, remainingAmount);
}
缺陷就在于混合计算本身。每个批次都有各自的代币数量、归属进度,以及按添加该批次时生效的资金池比率铸造的份额数量。因此,应分别计算每个批次的已归属份额,再将其相加。但合约先汇总代币数量和份额基数,再将单一归属比例应用于合并后的仓位,因为它只存储一个 D 和一个 totalAmount。只有各批次具有相同的每代币份额比率或相同的归属进度时,两种计算方法才等价。在追加存入前直接转入代币会改变资金池比率,因此新批次每单位代币获得的份额远少于原批次。此时,对两个批次应用单一混合归属比例,可能算出不再代表其合计已归属份额的 V。
由于 R 已从仓位的累计已归属份额中支付,一项账目一致的计划必须满足 R <= V。违反这一不等式就是混合计算缺陷的直接表现。代码没有为仓位单独维护有效份额余额,而是直接从全局 _totalTokenShares 中减去 D - V。假设其他仓位贡献了 1000 份份额,而该仓位的数值为 D = 100 和 R = 80。取消前,全局供应量包含 1,000 + (D - R) = 1,020 份份额。如果混合计算得出 V = 60,cancel() 就会减去 D - V = 40,使全局份额仅剩 980 份,比其他仓位贡献的 1000 份少 20 份。如果全局供应量仅包含该仓位的 20 份有效份额,减去 40 就会发生下溢并导致交易回滚;只有共享资金池中包含其他仓位贡献的份额,这笔超额扣减才会成功。随后,函数存储 _depositedShares[vestingId] = 60,而 schedule.released 仍为 80,使仓位记录处于不可能成立的状态 D < R。
代码既不强制满足 R <= V,也不将 sharesToRemove 限制在该仓位的有效份额以内。因此,取消操作可能扣减已不属于全局供应量的历史份额。与此同时,它直接以代币单位转出 totalAmount - vestedAmount,而不通过资金池的份额比率换算退款。取消操作本身不会产生超额支付;它只是将全局份额供应量降至各仓位记录的有效份额总和之下,为后续超额赎回创造条件。
攻击分析
以下分析以一笔有代表性的 Ethereum 交易 0x352065...c23823 为例。攻击合约在这笔交易中针对五种代币执行了相同的核心步骤,并对每种代币使用闪电流动性。
-
步骤 1:攻击者创建了一个立即归属的小仓位
#5058,以及两个规模大得多、立即归属且允许追加存入的软锁定仓位#5059和#5060。小仓位被保留用于最后提取,而大仓位提供了破坏全局记账所需的历史份额记录。 -
步骤 2:攻击者释放了两个大仓位。在最初接近 1:1 的资金池比率下,每次释放返还的数量都约等于各自的净存入量。相应份额从
_totalTokenShares中移除,但原始数值仍保留在仓位#5059和#5060的_depositedShares记录中,仅在各计划单独的released字段中记为已释放。 -
步骤 3:攻击者直接向
VestingManager转入代币。这恢复了代币余额,却没有恢复已释放的份额,因此每份剩余份额代表的代币数量大幅上升。 -
步骤 4:攻击者向已释放的两个大仓位追加存入。由于追加存入按新近升高的每份份额代币数量定价,大额代币存入仅铸造了少量新份额。此时,这些仓位同时包含庞大的过时历史基数和少量有效份额贡献。
-
步骤 5:攻击者取消了两个已追加存入的仓位。每次取消操作都将单一混合归属比例应用于聚合后的历史存入份额。重新计算的
vestedShare低于已释放份额,因此sharesToRemove超过了该仓位的有效贡献。尽管每个仓位在追加存入时只增加了少量有效份额,全局供应量却因每个仓位而减少了约5.0e25份虚增份额。 -
步骤 6:在分母被严重低估后,攻击者释放了保留的小仓位。在所举的代币实例中,约
5.73e22个原始单位的净存入量提取了约1.07e26个原始单位,约为存入量的 1,873 倍。攻击者利用扭曲后的比率重复创建和释放仓位的循环,并偿还了闪电流动性。

官方事件报告在 11 笔已确认的资金抽取交易中发现了相同模式。部分变体以原子方式完成,其他变体则在一笔关联交易中执行最终释放。UNCX 在剔除攻击者本金、临时闪电交易量和已返还资产后,根据已实现或经转移的收益计算出约 80.7609万 USD 的数字 [1]。
结论
根本原因是聚合份额记账存在缺陷。协议将以不同代币与份额比率创建的批次合并,再将单一归属比例应用于其聚合份额基数。这错误计算了累计已归属份额,使一个仓位能够移除超过其仍在贡献的份额,并导致仓位级记录与全局份额供应量出现偏差。攻击者利用被低估的全局供应量,从共享余额中提取了原本仍需用于兑付其他归属仓位的代币。
对于允许后续存入且份额比率会发生变化的记账模型,应保留每个批次的代币数量、份额基数和归属进度,而不是按单一比率混合计算。模型还应确保累计支付永不超过累计已归属份额,并确保仓位移除的份额不超过其有效贡献。
参考资料
[1] UNCX Network Smart Token Vesting V2 事件报告
Web3 最佳安全审计机构
上线前验证设计、代码和业务逻辑



