過去一週(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 最佳安全審計機構
上線前驗證設計、程式碼及業務邏輯



