科學家是怎麼開掛的:揭秘一個價值千萬的 NFT 漏洞
來源: @litangsongyx | 原文連結
日期: Sat Nov 29 09:33:33 +0000 2025
標籤:
智能合約安全隨機數漏洞DeFi 攻擊
我來整理這篇關於 NFT 盲盒漏洞的文章。這是一個非常經典的智能合約安全案例,涉及隨機數生成漏洞和區塊鏈存儲的特性。
整理完成的正文如下:
來源:@litangsongyx日期:2021 標籤:
NFT智能合約安全隨機數漏洞SolidityDeFi
事件背景
2021 年底,BSC 鏈上有個叫 Defina 的 GameFi 項目。遊戲上線前流行炒 NFT,NFT 根據稀有度定價,遊戲裡的 SSR 英雄卡二級市場地板價炒到了 6000 USDT 以上,而稀有度通過開盲盒來抽卡。
但在當時大家都很菜的情況下,這開盲盒所謂的「運氣」,其實全是「算術題」。
這場狩獵最終全網獲利接近 1000 萬人民幣。更有意思的是,項目方為了防科學家,連續換了 3 個版本的合約,但最終都被一一擊穿。
聲明:本文複盤的是 2021 年的歷史事件,旨在揭示「代碼即法律」在早期的黑暗森林中的真實博弈。文中提及的漏洞邏輯僅供學習 Solidity 安全機制,請勿在現實項目中模仿。Web3 安全建設不易,Respect developers.
第一回合:V1 版本 — 完全沒有防護
最初作者從 PancakeSwap 打新拿到了這個項目的幣,用幣買了盲盒來開英雄。結果和玩原神一樣,兩千 U 下去,無事發生,只出了張 SR 和幾張 R。
出於好奇,他去看了合約代碼,發現是開源的,就看了隨機函數。
當你看到一個涉及千萬資金的盲盒合約,其隨機數生成邏輯居然是這樣寫的時候,你就知道機會來了:
function _randModulus(uint mod) internal view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(
block.timestamp, // <--- 漏洞點 1: 礦工和科學家完全可知
block.difficulty, // <--- 漏洞點 2: 基本上也是固定變量
msg.sender // <--- 漏洞點 3: 攻擊者自己可控
))) % mod;
return rand;
}這在 Solidity 編程裡屬於教科書級別的反面教材。因為 block.timestamp(區塊時間戳)和 block.difficulty(區塊難度)在出塊的那一瞬間,對於礦工和科學家來說,是「已知量」。對於 BSC 這條鏈,當時固定的難度和固定的三秒出塊間隔,更讓這兩個數值完全可以預測。
邏輯解析
這段代碼的邏輯是:
使用 區塊時間 和 區塊難度 以及 發起地址 生成一個數字,用這個數字 / 卡池裡卡的數量 得到餘數,這個餘數在卡池中對應的卡牌,就是你抽出的卡牌。
破解方式
項目方也留了一手,他們把存放卡片 ID 的數組 cardIds 設為了 private(私有變量)。他們以為加上 private,大家就看不到這個數組了。
這太天真了。在 EVM 裡,private 只是防其他合約直接讀取,防不了科學家。
Storage 是完全透明的。甚至不需要去翻交易記錄,直接反編譯拿到這個數組存在合約的哪一段存儲插槽中,再用 web3.eth.getStorageAt 遍歷存儲插槽,SSR 到底對應哪個 Index,完全清清楚楚。
在這種情況下,寫一段腳本,卡準時間,開出你想要的 SSR 卡,已經是再簡單不過的事了。
第二回合:V2 版本 — 反而給科學家打助攻
至今都不知道 V2 版本是在什麼情況下上線的,可能項目方發現了有人在開 SSR,他們停用 V1,部署了 V2 合約。
項目方在慌亂之中,不僅沒有修復隨機數源頭的問題,反而把 open 函數裡唯一的防禦機制 onlyEOA 給刪掉了。
V1 vs V2 代碼對比
// V1: 只能用 EOA 地址調用
function open(uint tokenId) whenNotPaused onlyEOA external {
require(_isApprovedOrOwner(_msgSender(), tokenId), "...");
burn(tokenId);
// ... 開箱邏輯
}// V2: 居然把 onlyEOA 刪了!合約也能調了!
function open(uint tokenId) whenNotPaused public { // <--- 漏洞!
require(_isApprovedOrOwner(_msgSender(), tokenId), "...");
burn(tokenId);
// ... 開箱邏輯
}升級版破解方式
V1 時,還需要精準卡時間戳,如果卡慢了還得虧盲盒費。到了 V2,因為沒了 onlyEOA,完全可以寫個合約去調用開盲盒合約:
- 第一步:卡時間調用
open()開箱 - 第二步:檢查開出來的 NFT ID
- 第三步:如果不是 SSR,直接
revert()回滾交易
直接零成本試錯,最多虧個 gas,但 BSC 上的 gas 可以忽略不計。
第三回合:V3 版本 — 依然沒理解底層問題
被 V2 坑慘後,項目方急眼了。
這次他們學「聰明」了,上線了 V3 版本,並且採取了兩個看起來很硬核的措施:
- 閉源:不再公開合約代碼
- 人工注入種子:不再單純依賴時間戳,而是用腳本每隔 5 分鐘調用一次合約,注入一個外部隨機數種子,然後通過一系列計算,得出最終的隨機數種子
他們的邏輯是:「我不開源,你不知道我怎麼算的;我人工注入,你沒法預測我輸入什麼。」
依然可破解
不管你的計算邏輯多複雜,也不管是機器生成的還是人工餵的,最終生成的那個用來做隨機數的 Seed,只要你存在合約的 Storage 裡,科學家就能拿到。
破解路徑:
- 反編譯:雖然沒有源碼,但把 V3 的字節碼拉下來反編譯,分析出它是如何讀寫 Storage 的
- 定位插槽:找到了那個存儲「人工種子」的 Slot 位置
- 內存監控:腳本加一段代碼,實時監控這個 Slot 的值
滑稽的結果
場面變得很滑稽:
- 項目方每 5 分鐘辛苦地去鏈上餵一次數據
- 而腳本以 0.1 秒的頻率監控著那個位置
- 種子一更新,立刻讀出來,結合時間戳,繼續開想要的 SSR
結局:引入 Chainlink VRF
這場遊戲最終以項目方向 Chainlink 請救兵而告終。
他們在官方 Medium 發了一篇長長的報告,裡面有一句話讓人印象深刻:
"We had a theory about how the hacker exploited the contract but we are not able to repeat the action to prove the theory. Thus, we are offering a Bounty Program to the first person who could repeat his exploits on the testnet."
(我們推測了黑客的攻擊方式,但我們無法複現該操作。因此,我們推出漏洞賞金計劃,獎勵第一個能夠在測試網上重現該漏洞的人。)
來源:https://defina-finance.medium.com/important-notice-to-definians-a5f4a69e0ff5
最終,Defina 被迫遷移到了 V4 版本,並接入了 Chainlink VRF。這雖然貴,但是唯一的正解。
要不是有個哥們開得太猛太喪心病狂了,社區也不會發現這件事。
獲利規模
後來大概統計,全網的科學家獲利應該在千萬人民幣左右,早期 SSR 的價格達到 4000 Fina,而 Fina 的價格曾一度達到 10U。
技術總結
| 版本 | 防護措施 | 漏洞 | 破解方式 |
|---|---|---|---|
| V1 | private 數組 + onlyEOA | 可預測隨機數 + Storage 可讀 | 反編譯讀取 Storage,卡時間戳開盲盒 |
| V2 | 刪除 onlyEOA | 合約可調用 + 可預測隨機數 | 合約調用 + 不滿意就 revert,零成本試錯 |
| V3 | 閉源 + 人工注入種子 | 種子存在 Storage | 反編譯 + 監控 Storage Slot,實時讀取種子 |
| V4 | Chainlink VRF | 無明顯漏洞 | - |
核心教訓
- 鏈上隨機數生成的根本缺陷:
block.timestamp、block.difficulty、msg.sender這些都是可預測或可控的變量,不能用於生成安全的隨機數 - Storage 完全透明:
private關鍵字只能防合約間調用,防不了鏈下讀取 - 人工注入種子治標不治本:只要種子最終存在鏈上,就能被讀取
- 唯一正解是 Chainlink VRF:真正的隨機數需要鏈下預言機支持
在這個黑暗森林裡,代碼即法律,漏洞即利潤。